Skip to content

Latest commit

 

History

History
442 lines (359 loc) · 24.9 KB

File metadata and controls

442 lines (359 loc) · 24.9 KB

Waterfall (spectrogram) — accumulate, stream to PC, export

🇷🇺 Русская версия · ← README

The gateway can accumulate a waterfall (spectrogram): a sequence of spectra taken at fixed intervals. Each waterfall row is a delta of the cumulative spectrum over one interval — i.e. a finished "counts-per-period" spectrum, 8192 channels, uint16 per channel (16 KB per row).

You can:

  • record it to Flash autonomously as .aswf segments — no browser (#REC-11-A1): press "Start", close the tab — the board keeps recording on its own and survives a reboot / power loss (see Autonomous segment recording);
  • view it right in the board's browser — http://<board-ip>/waterfall;
  • stream it to a PC live over WebSocket;
  • pull finished .aswf segments over HTTP (/api/waterfall/segments/api/waterfall/segment?name=…) and stitch them on the PC / in the browser;
  • export it with the "⬇ Export .n42" button right from the Web UI — the board builds ANSI N42.42 from the PSRAM ring (works even without flash persistence);
  • convert to ANSI N42.42 with the scripts shipped in this repo;
  • open it as a 2D waterfall in the offline viewer shipped in this repo.

mDNS. The gateway announces itself as atomspectra.local (#REC-9) — everywhere below you may use http://atomspectra.local/ instead of <board-ip>.

What it looks like

Built-in waterfall Web UI (http://<board-ip>/waterfall): the spectrogram on top (time flows downward, X axis is channels/energy), with a slice of the current spectrum under the hovered row. Colour encodes intensity per the chosen palette and scale (log/lin).

Web UI — "Waterfall" tab (spectrogram + spectrum slice)

🔴 Live demo of this tab on real data (no board required): https://vibeengineering-llc.github.io/atomspectra-waterfall-esp32/demo/waterfall.html

Buttons Start / Stop / Clear / Export .n42, an Interval field, a keep in Flash checkbox, X-axis selector (channels/keV), brightness (log/lin), palette, contrast slider and channel zoom. The counter shows total rows · in ring (149/256) · in Flash.

Spectrogram palettes

Row colour is set by the chosen palette (the selector button above the spectrogram). 14 palettes are available; the choice is saved in the browser (localStorage, key aswf-pal) and applied to the whole waterfall on the fly (the palette is expanded into a 256-level LUT). Default is Inferno.

Palette Type Note
Inferno perceptual, warm default, high contrast on dark background
Magma perceptual, warm softer than Inferno, purple-pink
Plasma perceptual, warm purple→yellow, no black
Viridis perceptual, cool colour-blind friendly
Cividis perceptual, cool optimised for colour-blindness
Parula perceptual MATLAB default palette
Cubehelix monotone luminance survives black-and-white printing
Turbo rainbow Jet replacement with even luminance
Jet rainbow MATLAB classic
Spectral diverging blue↔yellow↔red
Hot thermal black→red→yellow→white
Ocean cool black→blue→white, calm
Cool vivid cyan→magenta
Grayscale mono for printing

For quantitative reading prefer the perceptually-uniform palettes (Inferno/Magma/Plasma/Viridis/Cividis/Parula/Cubehelix/Turbo) — they don't create false intensity "bands". Jet and the other rainbow maps are for eye-candy only.

How it works

Parameter Value Where in code
Channels 8192 (WF_CHANNELS) main/spectrogram.h
Row size 16 KB (WF_ROW_BYTES) main/spectrogram.h
PSRAM ring 256 rows × 16 KB = 4 MB (WF_RING_ROWS_DEFAULT) main/spectrogram.h
Default interval 5 s (WF_INTERVAL_DEFAULT), range 5…3600 s (WF_INTERVAL_MIN/WF_INTERVAL_MAX) main/spectrogram.h
Rows per segment 64 (WF_SEG_MAX_ROWS) ≈ 1 MB payload main/spectrogram.h
Segment finalised no later than 10 min (WF_SEG_MAX_AGE_SEC) main/spectrogram.h
Segment header reserve 4096 B (WF_HDR_RESERVE), payload at offset 4104 (WF_SEG_HEADER) main/spectrogram.h
Data type uint16 little-endian main/web_waterfall.c

While recording, rows accumulate into a PSRAM ring buffer (the latest 256 rows are always available for the window/stream). If persist is on, rows are also written to flash (LittleFS) — not as one file but as segments .aswf (see below): the unit of upload, the unit of the keep-last ring, and an independent file for stitching. When flash runs out of space the keep-last ring kicks in: the oldest not-yet-sent segment is overwritten, flash_full becomes true (= ring is active), the seg_evicted counter grows (safe — see below); the PSRAM ring and the WS stream keep running without interruption.

Calibration. The instrument's real energy calibration is a 5-coefficient polynomial (E(ch) = c₀ + c₁·ch + c₂·ch² + c₃·ch³ + c₄·ch⁴). It is delivered in the WebSocket text header (/ws/waterfall) and in the .aswf file header — not in the t1/t2/t3 fields of /api/spectrum.json. The tools in scripts/ read the calibration from the WS header, so the axis comes out in keV.

Autonomous segment recording (#REC-11-A1)

The key property: recording happens on the board and does not depend on the browser. The browser is only needed to press "Start" (and later "Stop") — after start you can close the tab, turn the PC off, and the board keeps recording on its own.

Once recording with persist is on, the board writes the waterfall to Flash as segments /storage/wf/seg_NNNNN.aswf (monotonic index). Each segment is a standalone valid .aswf (own header, own calibration, own started_at), so it can be pulled and read independently of the others.

Property Behaviour
Segment size up to 64 rows (WF_SEG_MAX_ROWS) ≈ 1 MB payload
Finalisation on reaching 64 rows OR after 10 min (WF_SEG_MAX_AGE_SEC) — so a large interval doesn't leave a file open for hours
Survives reboot on boot spectrogram_restore() reconciles /storage/wf: deletes empty stubs, restores the index; if recording was active — continues into a NEW segment (no mid-segment append, every file stays valid)
Flash full keep-last ring: the oldest not-yet-sent segment is overwritten; flash_full=true, seg_evicted grows (safe)
Row counter the header always carries saved_rows=0rows are derived from the file size (payload / row_stride); the header is never patched (patching an offset in LittleFS = copy-on-write of the whole file tail with a multi-second flash-cache freeze)
Open segment marked finalized:false in /segments (by the open file's index); no need to pull it before finalise

Total storage capacity ≈ 763 rows. At a 10-min interval that's ≈ 5.3 days of continuous recording, at a 1-hour interval ≈ 31 days (before the keep-last ring engages).

Stability (#STAB-2, 2026-07-04): a 9.40 h board-path recording run — 0 reboots, 0 seg_dropped, every SEG_ROLLOVER clean (see the #WF-1 fix in KNOWN_ISSUES.en.md). Full report: docs/stab2_report.md.

⚠ #FW-19: the n42 export only returns the last 256 rows (~4.25 h at a ~60 s cadence) — a separate limit from the ring_capacity field (/api/waterfall/status), smaller than the ~763-row partition-capacity estimate above. For recordings longer than ~4.25 h, pull segments periodically via /api/waterfall/segment (see below) instead of waiting until the end of the recording. Details: docs/stab2_report.md §6, KNOWN_ISSUES.en.md (#FW-19).

Serving a segment (/api/waterfall/segment?name=…) is strictly read-only: the board never deletes the file. Deletion is only by the keep-last ring (or the future A2 uploader after a successful send). So a browser/receiver can never erase data that hasn't been stitched yet.

Waterfall Web API

Endpoint Method Purpose
/waterfall GET Built-in waterfall Web UI (heatmap on the board itself)
/api/waterfall/status GET Waterfall status (JSON, see below)
/api/waterfall/start POST Start recording
/api/waterfall/stop POST Stop recording
/api/waterfall/clear POST Clear ring + flash segments (only while stopped). {"ok":true} only if seg_* is empty; "err":"recording" if recording; "err":"delete" if files remain (#FW-65)
/api/waterfall/config POST {"interval":N,"persist":bool} — interval (s) and flash persistence
/api/waterfall/window GET Ring snapshot (ASWW binary, up to 256 rows)
/api/waterfall/segments GET List of flash segments (JSON array, see below). No CSRF needed
/api/waterfall/segment?name=seg_NNNNN.aswf GET Raw segment file (application/octet-stream, read-only). Strict name validation (anti-traversal): seg_+digits+.aswf. 400 bad name / 404 not found
/api/waterfall/segment/delete?name=seg_NNNNN.aswf POST Delete a segment from flash after confirmed receipt on the PC (wf_pull_client.py, #REC-12). {"ok":true} / {"ok":false,"err":"not-deletable"} — segment still being written or pinned
/api/waterfall/export.n42 GET Export to ANSI N42.42 from the PSRAM ring (one <RadMeasurement> per row, CountedZeroes, calibration in <EnergyCalibration>). The "⬇ Export .n42" button in the Web UI. Does not require flash persistence
/api/waterfall/offload GET Push-offload config + stats (#REC-11-A2): {"enabled","url","user","has_pass","sent_ok","failed","last_status","last_ok_at","busy"}. The password is never returned
/api/waterfall/offload POST Set push-offload config: {"enabled":bool,"url":"http://…","user":"…","pass":"…"} (omit pass to keep the current one). A url with host narodmon is rejected (err:"narodmon-blocked")
/ws/waterfall WS Text header on connect, then one binary frame (16384 B) per new row

All POST endpoints require the X-CSRF-Token header (from GET /api/csrf-token), same as the rest of the gateway API.

GET /api/waterfall/status → JSON:

{
  "recording": false, "persist": false, "flash_full": false, "ready": true,
  "interval_sec": 5, "ring_capacity": 256, "ring_count": 0,
  "total_rows": 0, "flash_rows": 0,
  "seg_count": 0, "seg_lost": 0, "seg_evicted": 0, "seg_dropped": 0,
  "started_at": 0, "elapsed_sec": 0, "channels": 8192
}
  • ready — PSRAM ring allocated;
  • ring_count — valid rows in the ring (≤ ring_capacity);
  • total_rows — rows recorded since start (monotonic);
  • flash_rows — rows written to flash this session (monotonic);
  • flash_full — the keep-last ring is active (old segments being overwritten), not "flash is gone forever";
  • seg_count — finalised segments currently on Flash;
  • seg_lost#FW-57 (2026-08-19), real loss: boot reconciliation found a segment with size=0 (rows may have physically reached flash, but the inode never confirmed the size — power loss / crash reboot, see KNOWN_ISSUES.en.md P-016). Cumulative, survives reboot (NVS), resets only on /api/waterfall/clear. Previously this event and the safe ring eviction below were counted in one seg_dropped field — growth could not be told apart;
  • seg_evicted#FW-56, segments overwritten by the keep-last ring (safe: the segment was already offloaded or is stale). Also cumulative (NVS), also survives reboot;
  • seg_droppeddeprecated, kept for backwards compatibility: emitted as an alias of seg_lost. The field is public — external receivers and scripts read it, so removing it silently would break them without warning. The alias points at seg_lost (the alarming metric), not at the sum: a client that watched for "something was lost" must keep seeing losses, not routine rotation. New integrations should use seg_lost and seg_evicted; the field will not be removed before the next major version.

GET /api/waterfall/segments → JSON array (no CSRF). Each element:

[ {"name":"seg_00000.aswf","idx":0,"bytes":1052680,"rows":64,"finalized":true} ]
  • #FW-60/#FW-61: the listing is served from a RAM registry (updated at every point seg_count changes), not from a directory scan — used to take up to 1550 ms for four segments, now it's a LIVE-class response with no lock;
  • bytes is the actual file size (stat()), not computed by formula: segments left over from older format versions have a different row_stride and may lack the baseline section, so a formula based on the current version's geometry was wrong for them;
  • rows is a direct registry field, updated as rows are written (not only on finalization) — external rollover tracing sees it grow rather than sitting at a constant;
  • finalized:false — the segment is currently open — don't pull it. As of firmware-v1.2.17 (#FW-63), such a segment survives a sudden power loss too: header metadata is flushed to storage at least once a minute, so even an unfinalized segment is recovered by reconciliation at boot instead of being lost outright;
  • no directory yet / nothing recorded → [].

CPS monitoring (#MON-1, firmware-v1.2.16+)

The "Monitoring" tab shows the count rate over time, independently of waterfall recording. Architecture: the board itself, autonomously and continuously, accumulates one-second base samples in a PSRAM ring (12 h as of firmware-v1.2.16, #MON-3; 6 h before that) with a monotonic seq. Collection does not require the page to be open and does not stop when the tab is closed — the ring is the source of truth, the tab only reads its tail.

Endpoint Method What it does
/monitor GET Monitoring web UI
/api/monitor/series?since=<seq> GET Series tail starting from seq+1. Read-only, no CSRF

GET /api/monitor/series?since=<seq> → JSON:

{"epoch":74967625,"next_seq":1130,"first_seq":1,"interval_base":1,
 "samples":[[end_sec,dur,counts],...]}
  • epoch — series epoch; changes on spectrum/device reset or board restart (the ring is lost). A client that sees a new epoch must treat its local history as invalid and resync from first_seq;
  • first_seq / next_seq — the bounds of what's currently available in the ring; since=4294967294 (deliberately out of range) is a cheap way to poll the cursors only, without requesting the samples themselves;
  • samples — up to 2000 elements per response (MON_SERIES_CHUNK); a client that needs more catches up with a loop of requests until it reaches next_seq;
  • each sample [end_sec, dur, counts] is the device's acquisition time at the end of the interval, the interval duration (s), and the count increment over it. The client aggregates one-second base samples into "buckets" by its chosen averaging interval on its own — the board only supplies raw data.

The web page restores all available history on opening without an active recording session (before firmware-v1.2.16 it only showed what was collected after pressing "Start" — a UI defect, not a firmware one: the board was collecting data all along, the page just wasn't fetching it).

File formats

ASWW — window snapshot (/api/waterfall/window)

Compact header-less binary, streamed out (single 16 KB bounce buffer, no second 4 MB buffer → no OOM):

"ASWW" (4 bytes)
channels     u32 LE   (= 8192)
rows         u32 LE   (rows in the window)
first_index  u32 LE   (total index of the first row)
interval     u32 LE   (seconds between rows)
payload      = rows × channels × uint16 LE   (chronological, oldest first)

ASWF — self-describing file

Binary with a JSON header — everything needed to interpret it standalone. Common frame:

"ASWF" (4 bytes)
header_len   u32 LE          (JSON header length in bytes)
header       = JSON (utf-8), header_len bytes
payload      = rows × row-record   (v1: 16384 B; v2: row_stride B, see below)

.aswf is now written by two producers:

  1. PC script scripts/waterfall_client.py from the WS stream (/ws/waterfall) — format v1: a row record is exactly channels × uint16 LE (16384 B), variable header_len, key rows:

    {
      "format": "atomspectra-waterfall", "version": 1,
      "channels": 8192, "dtype": "uint16", "byte_order": "little",
      "rows": 1234, "interval_sec": 5, "started_at": 1750000000,
      "serial": "...", "calibration": [c0, c1, c2, c3, c4]
    }
  2. The firmware itself — segments /storage/wf/seg_NNNNN.aswf (#REC-11-A1), format v2: after the 16384 B of spectrum each row carries 2 B of real duration (uint16 LE, seconds of instrument live time) — a row record is 16386 B. The header is self-describing: row_stride = record size, row_time.offset = duration offset within the record. header_len is fixed = 4096 (WF_HDR_RESERVE; the JSON is space-padded, payload is always at offset 4104), and the counters saved_rows/saved_at come first, fixed-width and are always written by the firmware as zeros: saved_rows=0 means "derive the row count from the file size" ((bytes − 4104) / row_stride). The header is never modified after the segment is opened — patching an offset in LittleFS would mean copy-on-write of the whole file tail with a multi-second flash-cache freeze:

    {"saved_rows":       0,"saved_at":          0,"format":"atomspectra-waterfall",
     "version":2,"channels":8192,"dtype":"uint16","byte_order":"little",
     "row_stride":16386,"row_time":{"dtype":"uint16","unit":"sec","offset":16384},
     "interval_sec":5,"started_at":1782741288,"serial":"...","calibration":[c0,c1,c2,c3,c4]}

A reader that takes header_len from [4:8], parses that many JSON bytes (trailing spaces are ignored) and steps through the payload with row_stride (defaulting to channels×2 when the field is absent) handles both variants correctly. serial/calibration are present only if the instrument reported them. Files saved by the viewer/browser carry the actual saved_rows — a reader must accept both variants (0 = derive-from-size, >0 = authoritative value).

Per-row duration (dur) semantics and the timing model

Waterfall rows are closed by instrument live time (total_time_sec from STAT packets), not by the ESP32 clock — so the file stays honest even under USB loss:

  • Nominal dur = interval_sec — a regular row.
  • dur = interval_sec + 1 (rarely more) — a USB sweep was lost at the row boundary: its second honestly moves to the adjacent row, counts are never smeared. The counts/dur rate stays flat — which is exactly how files are validated (scripts/fw8_boundary_check.py).
  • dur = 0 is never written. Instrument goes silent (USB drops) → instrument time stands still → rows simply do not close, the waterfall pauses.
  • Spectrum commit time is robust to STAT packet loss: every full sweep = exactly 1 s of live time, every dropped sweep = 1 more second between commits; STAT is accepted no lower than this arithmetic (a rollback ≥5 s = instrument restart).
  • Segment rollover has no dead window: the next segment's header is preallocated (WF_HDR_RESERVE), rows are written immediately, finalisation is just fsync+fclose (the header is never patched, #FW-14).

Rendering: a row's height (time) = its dur; for v1 — interval_sec from the header.

started_at (segment anchor) — a DIFFERENT time source than dur. Each row's dur comes from the INSTRUMENT's own live clock (total_time_sec) and never depends on internet access — the relative spacing between rows is always honest. started_at in the segment header, on the other hand, is the board's time(NULL) (ESP32 wall clock), synced via SNTP (pool.ntp.org, init_sntp(), one outgoing request at boot, no retry/success check). If the board never had internet access, time(NULL) is unsynced and started_at sits near the Unix epoch (a real case with started_at=1 has been observed). Autonomous recording still works fine — only the ABSOLUTE time anchor is lost (the date in the header / N42 export), the row durations (dur) stay real. The anchor is re-captured on EVERY new segment — if internet appears mid-recording, segments before and after get different (inconsistent) started_at values, and the jump between them is NOT detected as a "board pause" (see wf_pull_client.py, gap detection is explicitly disabled when started_at ≤ 1e9).

WebSocket header (/ws/waterfall)

The first frame after connect is a text JSON:

{ "type": "header", "channels": 8192, "interval_sec": 5, "total_rows": 187,
  "serial": "...", "calibration": [c0, c1, c2, c3, c4] }

Then one binary frame per new row (16384 bytes = 8192 × uint16 LE). Up to 4 WS clients are supported at once.

Streaming to a PC and N42 export

scripts/ ships a set of tools (require pip install requests websocket-client):

waterfall_n42.py — export to ANSI N42.42-2012

ANSI N42.42 (IEC 62755) is the XML gamma-spectrometry interchange format understood by InterSpec, PeakEasy, Cambio. Each waterfall row becomes one <RadMeasurement> with RealTimeDuration = PT{interval}S; sparse delta rows are compressed with CountedZeroes. The calibration is written to <EnergyCalibration> (polynomial from the WS header).

# board ring snapshot → snapshot.n42
python waterfall_n42.py window  <board-ip> -o snapshot.n42

# live stream to N42, auto-stop after 360 s (recording must be ON on the board)
python waterfall_n42.py stream  <board-ip> -o live.n42 --seconds 360

# convert a previously captured .aswf (pull calibration from the board via --host)
python waterfall_n42.py convert capture.aswf -o capture.n42 --host <board-ip>

Options: --detector CsI|NaI|LaBr3|... (default CsI), -o/--out.

waterfall_viewer.html — offline waterfall viewer

A standalone HTML page (no server, no dependencies): open it in a browser and drag a .n42 file onto it — it renders a heatmap (time ↓ × energy →, viridis palette). Controls: channel range, detail, log/contrast, hover tooltip with energy in keV (when calibration is present). You can also pass a file via ?src=name.n42 when serving over http://.

Offline viewer waterfall_viewer.html — waterfall heatmap from .n42

A ready-made sample example-waterfall.n42 (export of a real run) ships with the repo — drag it into the viewer to see the result right away without connecting to a board.

waterfall_client.py — capture to .aswf

Writes an unbounded .aswf from the WS stream (not limited by the board's 256-row ring). Stop with Ctrl+C — the header with the final row count is written on exit. The .aswf can then be converted to N42 via waterfall_n42.py convert.

wf_pull_client.py / wf_recorder_app.py — pull recording with on-the-fly stitching (#REC-12)

An alternative to waterfall_client.py aimed at multi-hour/multi-day recording without holding a live WS connection: the script periodically polls /api/waterfall/segments, pulls each finalized segment exactly once, appends its rows to a single growing .aswf, and deletes the segment on the board only after the local file is fsynced — so a dropped connection or crash mid-transfer never loses or duplicates rows. Progress (which segments are already ingested, running row/duration totals) is tracked in a sidecar <file>.state.json next to the .aswf, so stopping and re-running against the same file resumes the recording instead of restarting it.

wf_recorder_app.py (launch by double-clicking wf_recorder.bat) is a desktop GUI (tkinter) on top of the same logic: ▶ Start/⏸ Stop buttons, New file… (stops the current recording, lets you pick a new path, and wipes that path's .aswf + .state.json + .temps.csv if they already exist — so you start from a genuinely clean slate instead of resuming an old recording) and Open folder; it shows live counters for rows in the file, duration, instrument temperature, and board segment counts.

python wf_pull_client.py <board-ip> --stitch capture.aswf --interval 60

What opens N42 / .aswf

Tool By Note
InterSpec Sandia Best choice; shows the measurement time-history
waterfall_viewer.html this repo 2D waterfall (heatmap), offline, .n42 and .aswf
waterfall-viewer separate repo Advanced native viewer: 3D waterfall render, 2D map, a slice/section/sample panel
PeakEasy LANL Spectrum viewer
Cambio Sandia Converter/viewer