atomspectra-waterfall-esp32

Водопад (спектрограмма) — накопление, стрим на ПК, экспорт

🇬🇧 English · ← README

Шлюз умеет копить водопад (спектрограмму): последовательность спектров, снятых через равные интервалы. Каждая строка водопада — это дельта накопительного спектра за интервал, т.е. законченный спектр «импульсы за период», 8192 канала, uint16 на канал (16 КБ на строку).

Водопад можно:

mDNS. Шлюз анонсирует себя как atomspectra.local (#REC-9) — везде ниже вместо <IP-платы> можно писать http://atomspectra.local/.

Как выглядит

Встроенный Web UI водопада (http://<IP-платы>/waterfall): сверху — спектрограмма (время идёт вниз, ось X — каналы/энергия), под ней — срез текущего спектра по наведённой строке. Цвет — интенсивность по выбранной палитре и шкале (лог/лин).

Web UI — вкладка «Водопад» (спектрограмма + срез спектра)

🔴 Живое демо этой вкладки на реальных данных (плата не нужна): https://vibeengineering-llc.github.io/atomspectra-waterfall-esp32/demo/waterfall.html

Кнопки Старт / Стоп / Очистить / Экспорт .n42, поле Интервал, флажок хранить во Flash, выбор оси X (каналы/кэВ), яркости (лог/лин), палитры, ползунок контраста и зум по каналам. Счётчик показывает строк всего · в кольце (149/256) · во Flash.

Палитры спектрограммы

Цвет строки задаётся выбранной палитрой (кнопка выбора над спектрограммой). Доступно 14 палитр; выбор сохраняется в браузере (localStorage, ключ aswf-pal) и применяется ко всему водопаду на лету (палитра разворачивается в 256-уровневый LUT). По умолчанию — Inferno.

Палитра Тип Заметка
Inferno перцептивная, тёплая по умолчанию, контрастна на тёмном фоне
Magma перцептивная, тёплая мягче Inferno, фиолетово-розовая
Plasma перцептивная, тёплая фиолет→жёлт, без чёрного
Viridis перцептивная, холодная дружелюбна к дальтонизму
Cividis перцептивная, холодная оптимизирована под дальтонизм
Parula перцептивная палитра MATLAB по умолчанию
Cubehelix монотонная яркость корректна и в ч/б печати
Turbo радужная замена Jet с равномерной яркостью
Jet радужная классика MATLAB
Spectral дивергентная синий↔жёлтый↔красный
Hot термальная чёрный→красн→жёлт→белый
Ocean холодная чёрный→синий→белый, спокойная
Cool яркая голубой→пурпурный
Grayscale моно для печати

Для количественной оценки предпочтительны перцептивно-равномерные палитры (Inferno/Magma/Plasma/Viridis/Cividis/Parula/Cubehelix/Turbo) — они не создают ложных «полос» интенсивности. Jet и прочие радужные — только для наглядности.

Как это устроено

Параметр Значение Где в коде
Каналов 8192 (WF_CHANNELS) main/spectrogram.h
Размер строки 16 КБ (WF_ROW_BYTES) main/spectrogram.h
Кольцо в PSRAM 256 строк × 16 КБ = 4 МБ (WF_RING_ROWS_DEFAULT) main/spectrogram.h
Интервал по умолчанию 5 с (WF_INTERVAL_DEFAULT), диапазон 5…3600 с (WF_INTERVAL_MIN/WF_INTERVAL_MAX) main/spectrogram.h
Строк в сегменте 64 (WF_SEG_MAX_ROWS) ≈ 1 МБ payload main/spectrogram.h
Финализация сегмента не реже 10 мин (WF_SEG_MAX_AGE_SEC) main/spectrogram.h
Резерв заголовка сегмента 4096 Б (WF_HDR_RESERVE), payload с offset 4104 (WF_SEG_HEADER) main/spectrogram.h
Тип данных uint16 little-endian main/web_waterfall.c

Запись (recording) копит строки в кольцевой буфер PSRAM (последние 256 строк всегда под рукой для окна/стрима). Если включён persist, строки пишутся ещё и во flash (LittleFS) — но не одним файлом, а сегментами .aswf (см. ниже): это единица отправки, единица кольца keep-last и независимый файл для склейки. Когда место на flash кончается, включается кольцо keep-last: затирается самый старый ещё не отправленный сегмент, flash_full становится true (= кольцо активно), счётчик seg_dropped растёт; кольцо PSRAM и WS-стрим продолжают работать без перерыва.

Калибровка. Реальная энергетическая калибровка прибора — это полином из 5 коэффициентов (E(ch) = c₀ + c₁·ch + c₂·ch² + c₃·ch³ + c₄·ch⁴). Она приходит в текстовом заголовке WebSocket (/ws/waterfall) и в заголовке файла .aswf, а не в полях t1/t2/t3 из /api/spectrum.json. Инструменты в scripts/ берут калибровку именно из WS-заголовка, поэтому ось получается в кэВ.

Автономная запись сегментами (#REC-11-A1)

Главное свойство: запись идёт на стороне платы и не зависит от браузера. Браузер нужен только чтобы нажать «Старт» (и потом «Стоп») — после старта вкладку можно закрыть, выключить ПК, и плата продолжит писать сама.

Как только включена запись с persist, плата пишет водопад во Flash сегментами /storage/wf/seg_NNNNN.aswf (монотонный индекс). Каждый сегмент — самостоятельный валидный .aswf (свой заголовок, своя калибровка, свой started_at), поэтому его можно забрать и прочитать независимо от остальных.

Свойство Поведение
Размер сегмента до 64 строк (WF_SEG_MAX_ROWS) ≈ 1 МБ payload
Финализация по достижении 64 строк ИЛИ через 10 мин (WF_SEG_MAX_AGE_SEC) — чтобы при больших интервалах файл не висел открытым часами
Переживание ребута на boot spectrogram_restore() сверяет /storage/wf: удаляет пустые огрызки, восстанавливает индекс; если запись была активна — продолжает в НОВЫЙ сегмент (mid-segment append не делается, каждый файл остаётся валидным)
Flash заполнен кольцо keep-last: затирается самый старый ещё не отправленный сегмент; flash_full=true, растёт seg_dropped
Счётчик строк шапка всегда несёт saved_rows=0строки выводятся из размера файла (payload / row_stride); шапка никогда не патчится (патч offset в LittleFS = copy-on-write всего хвоста файла с многосекундной заморозкой flash-кэша)
Открытый сегмент в /segments помечен finalized:false (по индексу открытого файла); забирать в браузер до finalize не нужно

Ёмкость storage ≈ 763 строки суммарно. При интервале 10 мин это ≈ 5.3 суток непрерывной записи, при 1-часовом интервале ≈ 31 сутки (до включения кольца keep-last).

Стабильность (#STAB-2, 2026-07-04): 9.40-часовой прогон записи через плату — 0 ребутов, 0 seg_dropped, все SEG_ROLLOVER чистые (см. фикс #WF-1 в KNOWN_ISSUES.md). Полный отчёт: docs/stab2_report.md.

⚠ #FW-19: экспорт n42 отдаёт только последние 256 строк (~4.25 ч при кадансе ~60 с) — это отдельный лимит поля ring_capacity (/api/waterfall/status), меньше оценки ёмкости раздела (763 строки) выше. Для записей длиннее ~4.25 ч забирать сегменты периодически через /api/waterfall/segment (см. ниже), не дожидаясь конца записи. Подробности: docs/stab2_report.md §6, KNOWN_ISSUES.md (#FW-19).

Отдача сегмента (/api/waterfall/segment?name=…) — строго read-only: плата файл не удаляет. Удаление — только кольцо keep-last (или будущий A2-аплоадер после успешной отправки). Так браузер/приёмник не может случайно стереть ещё не склеенные данные.

Web API водопада

Эндпоинт Метод Что делает
/waterfall GET Встроенный Web UI водопада (heatmap прямо на плате)
/api/waterfall/status GET Статус водопада (JSON, см. ниже)
/api/waterfall/start POST Начать запись
/api/waterfall/stop POST Остановить запись
/api/waterfall/clear POST Очистить кольцо + сегменты на flash (только когда запись остановлена)
/api/waterfall/config POST {"interval":N,"persist":bool} — интервал (с) и запись во flash
/api/waterfall/window GET Снимок кольца (бинарь ASWW, до 256 строк)
/api/waterfall/segments GET Список сегментов на Flash (JSON-массив, см. ниже). Не требует CSRF
/api/waterfall/segment?name=seg_NNNNN.aswf GET Сырой файл сегмента (application/octet-stream, read-only). Строгая валидация имени (anti-traversal): seg_+цифры+.aswf. 400 bad name / 404 not found
/api/waterfall/segment/delete?name=seg_NNNNN.aswf POST Удалить сегмент с flash после подтверждённого приёма на ПК (wf_pull_client.py, #REC-12). {"ok":true} / {"ok":false,"err":"not-deletable"} — сегмент ещё пишется или запинен
/api/waterfall/export.n42 GET Экспорт в ANSI N42.42 из кольца PSRAM (одна <RadMeasurement> на строку, CountedZeroes, калибровка в <EnergyCalibration>). Кнопка «⬇ Экспорт .n42» в Web UI. Не требует записи во flash
/api/waterfall/offload GET Конфиг + статистика автовыгрузки сегментов push-методом (#REC-11-A2): {"enabled","url","user","has_pass","sent_ok","failed","last_status","last_ok_at","busy"}. Пароль наружу никогда не отдаётся
/api/waterfall/offload POST Задать конфиг автовыгрузки: {"enabled":bool,"url":"http://…","user":"…","pass":"…"} (без pass — прежний пароль сохраняется). url с хостом narodmon отвергается (err:"narodmon-blocked", БАН Народмон)
/ws/waterfall WS Текстовый заголовок при подключении, далее по одному бинарному кадру (16384 Б) на каждую новую строку

Все POST требуют заголовок X-CSRF-Token (получить из GET /api/csrf-token), как и остальной 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_dropped": 0,
  "started_at": 0, "channels": 8192
}

GET /api/waterfall/segments → JSON-массив (без CSRF). Каждый элемент:

[ {"name":"seg_00000.aswf","idx":0,"bytes":1052680,"rows":64,"finalized":true} ]

Форматы файлов

ASWW — снимок окна (/api/waterfall/window)

Компактный бинарь без заголовка-JSON, отдаётся потоково (один 16-КБ bounce-буфер, без второго 4-МБ буфера → нет OOM):

"ASWW" (4 байта)
channels     u32 LE   (= 8192)
rows         u32 LE   (строк в окне)
first_index  u32 LE   (total-индекс первой строки)
interval     u32 LE   (секунд между строками)
payload      = rows × channels × uint16 LE   (хронологически, старейшая первой)

ASWF — самодостаточный файл

Бинарь с JSON-заголовком — содержит всё для автономной интерпретации. Общий каркас:

"ASWF" (4 байта)
header_len   u32 LE          (длина заголовка-JSON в байтах)
header       = JSON (utf-8), header_len байт
payload      = rows × запись-строки   (v1: 16384 Б; v2: row_stride Б, см. ниже)

.aswf теперь пишут два источника:

  1. PC-скрипт scripts/waterfall_client.py из WS-стрима (/ws/waterfall) — формат v1: запись строки = ровно channels × uint16 LE (16384 Б), переменный header_len, ключ 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. Сама прошивка — сегменты /storage/wf/seg_NNNNN.aswf (#REC-11-A1), формат v2: после 16384 Б спектра каждая строка несёт 2 Б реальной длительности (uint16 LE, секунды живого времени прибора) — запись строки = 16386 Б. Шапка самоописываема: row_stride = размер записи, row_time.offset = смещение длительности внутри записи. header_len фиксирован = 4096 (WF_HDR_RESERVE; JSON добивается пробелами, payload всегда с offset 4104), а счётчики saved_rows/saved_at идут первыми фиксированной ширины и прошивкой всегда пишутся нулями: saved_rows=0 = «строк — из размера файла» ((bytes − 4104) / row_stride). Шапка после открытия сегмента никогда не меняется — патч по offset в LittleFS означал бы copy-on-write всего хвоста файла с многосекундной заморозкой flash-кэша:

    {"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]}
    

Читатель, который берёт header_len из [4:8], парсит столько байт JSON (пробелы в хвосте игнорируются) и шагает по payload с шагом row_stride (по умолчанию channels×2, если поля нет), корректно разбирает оба варианта. Поля serial/calibration присутствуют только если прибор их сообщил. Файлы, сохранённые вьюером/браузером, несут фактический saved_rows — читатель обязан понимать оба варианта (0 = derive-from-size, >0 = авторитетное значение).

Семантика per-row длительности (dur) и модель времени

Строки водопада закрываются по живому времени прибора (total_time_sec из STAT-пакетов), а не по часам ESP32 — поэтому файл честен даже при потерях USB:

Рендер: высота (время) строки = её dur; для v1 — interval_sec из шапки.

started_at (якорь сегмента) — ДРУГОЙ источник времени, чем dur. dur каждой строки берётся с живых часов ПРИБОРА (total_time_sec) и от интернета никак не зависит — реляционные интервалы между строками честны всегда. А вот started_at в шапке сегмента = time(NULL) платы (ESP32 wall clock), который синкается по SNTP (pool.ntp.org, init_sntp(), только исходящий запрос при старте, без ретрая/проверки успеха). Если у платы никогда не было интернета — time(NULL) не синкан, started_at близок к эпохе (наблюдался случай started_at=1). Автономная запись при этом идёт нормально — теряется только АБСОЛЮТНАЯ привязка ко времени (дата в шапке / экспорт N42), сами длительности строк (dur) остаются реальными. Якорь берётся заново при открытии КАЖДОГО нового сегмента — если интернет появился посреди записи, сегменты до и после получат разные (несогласованные) started_at, а скачок между ними НЕ детектируется как «пауза платы» (см. wf_pull_client.py, детекция gap явно отключена при started_at ≤ 1e9).

Заголовок WebSocket (/ws/waterfall)

Первый кадр после подключения — текстовый JSON:

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

Дальше — по одному бинарному кадру на каждую новую строку (16384 байта = 8192 × uint16 LE). Одновременно поддерживается до 4 WS-клиентов.

Стрим на ПК и экспорт в N42

В scripts/ — набор инструментов (нужны pip install requests websocket-client):

waterfall_n42.py — экспорт в ANSI N42.42-2012

ANSI N42.42 (IEC 62755) — XML-формат обмена гамма-спектрометрией, который понимают InterSpec, PeakEasy, Cambio. Каждая строка водопада → один <RadMeasurement> с RealTimeDuration = PT{interval}S, разреженные дельта-строки сжаты CountedZeroes. Калибровка пишется в <EnergyCalibration> (полином из WS-заголовка).

# снимок кольца платы → snapshot.n42
python waterfall_n42.py window  <board-ip> -o snapshot.n42

# живой стрим в N42, авто-стоп через 360 с (запись на плате должна быть включена)
python waterfall_n42.py stream  <board-ip> -o live.n42 --seconds 360

# конвертировать ранее снятый .aswf (калибровку добрать с платы по --host)
python waterfall_n42.py convert capture.aswf -o capture.n42 --host <board-ip>

Опции: --detector CsI|NaI|LaBr3|... (по умолчанию CsI), -o/--out.

waterfall_viewer.html — офлайн-просмотрщик водопада

Автономная HTML-страница (без сервера, без зависимостей): открой в браузере и перетащи .n42 внутрь — рисуется heatmap (время ↓ × энергия →, палитра viridis). Управление: диапазон каналов, детализация, log/контраст, hover-подсказка с энергией в кэВ (если есть калибровка). Можно отдать файл и через ?src=имя.n42 при открытии через http://.

Офлайн-вьюер waterfall_viewer.html — heatmap водопада из .n42

В репозитории лежит готовый образец example-waterfall.n42 (экспорт реального прогона) — перетащи его во вьюер, чтобы сразу увидеть результат без подключения к плате.

waterfall_client.py — захват в .aswf

Пишет неограниченный по длине .aswf из WS-стрима (не упирается в 256-строчное кольцо платы). Останов по Ctrl+C — заголовок с финальным числом строк дописывается на выходе. Затем .aswf можно сконвертировать в N42 через waterfall_n42.py convert.

wf_pull_client.py / wf_recorder_app.py — pull-запись с автосшивкой (#REC-12)

Альтернатива waterfall_client.py, рассчитанная на многочасовую/многодневную запись без постоянного WS-соединения: скрипт периодически опрашивает /api/waterfall/segments, забирает каждый завершённый сегмент один раз, дописывает его строки в единый растущий .aswf и только после fsync на диск удаляет сегмент с платы — обрыв связи/краш посередине не теряет данные и не дублирует строки. Прогресс (какие сегменты уже вшиты, накопленные строки/длительность) хранится в <файл>.state.json рядом с .aswf, поэтому остановка и повторный запуск того же файла продолжают запись, а не начинают её заново.

wf_recorder_app.py (запуск двойным кликом по wf_recorder.bat) — desktop-GUI (tkinter) поверх той же логики: кнопки ▶ Старт/⏸ Стоп, Новый файл… (останавливает текущую запись, даёт выбрать новый путь и стирает по нему .aswf+.state.json+.temps.csv, если уже есть — чтобы начать заведомо с чистого листа, а не продолжить старую запись) и Открыть папку; вживую показывает строки в файле, длительность, температуру прибора и счётчики сегментов на плате.

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

Чем открывать N42 / .aswf

Инструмент Кто Заметка
InterSpec Sandia Лучший выбор; показывает time-history измерений
waterfall_viewer.html этот репозиторий 2D-водопад (heatmap) офлайн, .n42 и .aswf
waterfall-viewer отдельный репозиторий Продвинутый нативный вьюер: 3D-водопад, 2D-карта, панель «Срезы/Сечения/Выборки»
PeakEasy LANL Просмотр спектров
Cambio Sandia Конвертер/просмотрщик