Водопад

src/soniks_client/waterfall/ делает две разные вещи: рисует PNG-водопад для портала и проводит самостоятельный анализ сигнала, результат которого уезжает как metadata["signal"].

Пакет разложен по слоям — каждый следующий зависит только от предыдущего:

Модуль

Что внутри

Зависимости

dsp.py

12 приватных примитивов numpy: обнаружение всплесков, поиск пиков, полосы, k-means-2

только numpy

deviation.py

_estimate_deviation_v7 — единственный оценщик девиации, вызывает примитивы из dsp

numpy, dsp

plot.py

класс Waterfall: чтение .dat, отрисовка, метаданные. Весь matplotlib

deviation, core

__init__.py

реэкспорт Waterfall — импорт снаружи остаётся from soniks_client.waterfall import Waterfall

Всё, кроме класса Waterfall, приватное: имена начинаются с _ и наружу не выдаются.

Примечание

До фазы 3 это был один файл на 2214 строк, в котором около 900 строк не вызывались ниоткуда — остатки прежних поколений оценщика (_estimate_deviation_fft_only, _compute_collision_doppler_curve и их хелперы). Они удалены; оценщик теперь один, и запасных веток у него нет. Если имя из старых обсуждений не находится — ищите его в истории git, а не в коде.

Публичный интерфейс

wf = Waterfall(datafile_path, output_path)   # читает .dat, может бросить исключение
wf.plot(vmin=None, vmax=None)                # обе границы необязательны
signal = wf.get_signal_metadata()            # dict | None

Конструктор сразу читает файл и проверяет, что данные есть. Три исключения:

Исключение

Когда

WaterfallFileError

Файл не найден, это не файл, заголовок обрезан или не читается

EmptyWaterfallError

Файл прочитан, но массив отсчётов пуст

TimestampError

Метка времени в заголовке не разбирается

Вызывающий код — post_processing.build_waterfall() — ловит все три и продолжает выгрузку без водопада: отсутствие картинки не повод терять аудио и кадры. Сырой .dat при этом остаётся на диске — он удаляется только после успешного plot(), чтобы упавшее построение можно было воспроизвести.

get_signal_metadata(), вызванный до plot(), возвращает None: анализ наполняется в ходе отрисовки.

Формат файла

Потоковый граф пишет сырой водопад в бинарный .dat с заголовком фиксированного размера, затем строками спектра:

Поле

Тип

Смысл

timestamp

S32

Метка времени начала, ASCII

nchan

>i4

Число частотных каналов (ширина БПФ)

samp_rate

>i4

Частота дискретизации

nfft_per_row

>i4

Сколько БПФ усреднено в одну строку

center_freq

>f4

Центральная частота

endianness

<i4

Порядок байтов остальных данных

Далее — записи (tabs: i8, spec: f4[nchan]): относительная метка времени и строка спектра. Порядок байтов определяется полем endianness, поле center_freq при этом всегда читается big-endian — так пишет граф.

Из этого восстанавливаются ось времени (trel) и ось частот (freq, симметричная относительно нуля в диапазоне ±samp_rate/2).

Файл крупный и по умолчанию удаляется после отрисовки (OBSERVATION__REMOVE_WATERFALL_RAW_FILES=True). Для отладки алгоритмов выключите удаление — иначе воспроизвести обработку будет не на чем.

Отрисовка

plot() рисует PNG размером WATERFALL__WIDTH × WATERFALL__HEIGHT дюймов с раскладкой из WATERFALL__GRIDSPEC (полотно плюс цветовая шкала).

Границы динамического диапазона подбираются, только если хотя бы одна из них не передана в plot(). Штатный вызов из post_processing() идёт без аргументов, поэтому настройки ниже действительно работают:

  • WATERFALL__AUTORANGE=False (по умолчанию) — фиксированные WATERFALL__MIN_VALUE / MAX_VALUE, по умолчанию −110 и −50 дБ;

  • AUTORANGE=True — робастная оценка уровня шума по данным (сигма-клиппинг). Отсчёты фильтруются np.isfinite и порогом WATERFALL__THRESHOLD (−200 дБ): isfinite убирает -inf пустых бинов, порог — заведомо нефизические уровни. Если после фильтрации осталось меньше WATERFALL__MIN_VALID_SAMPLES (10000) отсчётов, берутся DEFAULT_MIN_VALUE / DEFAULT_MAX_VALUE, а в лог уходит warning.

WATERFALL__HELP_LINES=True добавляет поверх водопада вспомогательную сетку.

Анализ сигнала

get_signal_metadata() форматирует накопленный в self.analysis результат в плоский словарь под схему портала. Ключи со значением None выбрасываются, поэтому состав ответа зависит от того, что удалось измерить. Если анализа не было (plot() ещё не вызывался), метод возвращает None целиком.

Группы измерений:

Группа

Ключи

Отношение сигнал/шум

snr_db, snr_quality, noise_db, noise_std_db, peak_db, peak_freq_hz

Модуляция

modulation, n_spec_peaks, valley_depth_db

Девиация

deviation_hz, deviation_uncertainty_hz, deviation_confidence, deviation_method, f_low_hz, f_high_hz

Полоса

bw_3db_hz, bw_10db_hz, bw_20db_hz, bw_26db_hz, bw99_hz, sigma_rms_hz

Активность

n_bursts, n_valid_rows, saturated

Частота

carrier_offset_hz, ppm_error, drift_ppb, measured_freq_mhz

Прочее

method — текстовая пометка о том, каким путём получен результат

Геометрия водопада

samp_rate_hz, bin_hz, nchan, nfft_per_row, noise_dbhz

Величины с размерностью — строки с единицами измерения ("1234.5 Hz", "-98.3 dB", "437.050000 MHz", "1.234 ppm"), так их ожидает портал; при изменении формата сломается отображение на стороне сайта. Счётчики n_spec_peaks, n_bursts, n_valid_rows уходят числами, saturatedbool (опора цели упёрлась в край полосы: сигнал шире водопада или приёмник перегружен; порталу — считать долю таких проходов, Фаза 4), а modulation, deviation_method и method — текстом как есть.

Примечание

Ключ bw99_hz в ответе портала читается из bw_99_hz словаря оценщика. Одноимённый bw99_hz внутри самого оценщика — legacy: ему никто не присваивает значения, и он всегда None.

Геометрия берётся из заголовка .dat, а не из оценщика, поэтому есть даже там, где анализ не состоялся. Она нужна порталу: полоса водопада равна out_samp_rate графа и зависит от режима (48 кГц у FM, 57.6 кГц у FSK 9600, 76.8 кГц у BPSK, 66.56 кГц у APT/SSTV, 200 кГц у PHASMA) при постоянном БПФ на 1024 точки, так что noise_db — мощность в бине — между режимами несравним. noise_dbhz = noise_db 10·lg(bin_hz) — плотность шума, сопоставимая между режимами и станциями с разной геометрией (Фаза 3.3 в дорожной карте сети). Формула точна для белого шума при прямоугольном окне и масштабе 1/fft_size waterfall_sink; смещение режима Max Hold (10·lg(H_n) по nfft_per_row, от 3.2 дБ при 4 БПФ на строку до 5.5 дБ при 19) не вычитается — режим sink в заголовке не записан.

Практическая польза: ppm_error и carrier_offset_hz показывают уход опорного генератора вашего приёмника. Систематическое смещение из наблюдений — готовое значение для FLOWGRAPH__PPM_ERROR.

Валидация

Оценщик проверяется на синтетических спектрограммах в tests/test_deviation.py. Генератор _synthetic_spectrum() повторяет то, что пишет waterfall_sink: мощность комплексного гауссова шума в бине распределена экспоненциально, Max Hold берёт максимум по nfft_per_row БПФ, итог в дБ; тона — гауссовы лепестки в линейной мощности. Отсюда проверяемое смещение полки Max Hold — 10·lg(H_n), 4.34 дБ при 8 БПФ на строку.

Каждый тест закрепляет одну ветку конвейера из шапки deviation.py: k-means по пикам строк (тона ±Δf через строку), пара пиков усреднённого спектра (оба тона в каждой строке, один сильнее), σ_RMS для одного лепестка, CW, weak/noise, смещение несущей и ppm, изоляция помехи, насыщение, guard на малом водопаде — и ключевое утверждение Фазы 3.3: один N0 при разной ширине бина даёт один noise_dbhz.

Потолки, которые тесты закрепляют как есть, а не чинят:

  • interferer_rejected значит «оценён сигнал не по центру», а не «помеха отброшена»: слабая далёкая помеха отбрасывается молча с флагом False, сильная — измеряется с флагом True;

  • непрерывный сигнал всегда классифицируется как CW Morse (bursty …): при всех горячих строках _detect_burst_rows находит меньше пяти, фолбэк по 75-му перцентилю даёт bursty_fraction 0.25, ветка CW continuous недостижима;

  • критерий насыщения bw99 > 0.85·samp_rate недостижим из-за среза ±0.45 (шапка обещает /2); saturated ловит только касание края опорой цели, а широкий центрированный сигнал уходит в weak/noise — робастный шум берёт медиану самого сигнала;

  • legacy-ключи словаря оценщика (bw99_hz, bw95_hz, w30_*, delta_f_*_hz) никем не присваиваются.

Работа с модулем

  • Правка алгоритма — это deviation.py; правка отрисовки — plot.py. Общие численные примитивы в dsp.py трогайте в последнюю очередь: их использует оценщик в десятке мест.

  • Аннотации типов здесь почти отсутствуют (в отличие от остального кода), и autodoc покажет голые сигнатуры. Это известное состояние.

  • Приватные хелперы в Справочник API не выводятся — документируются только Waterfall, plot() и get_signal_metadata().

  • Для воспроизводимой отладки сохраняйте .dat-файлы: OBSERVATION__REMOVE_WATERFALL_RAW_FILES=False.