13 Commits

38 changed files with 1561 additions and 7532 deletions

15
.gitignore vendored
View File

@ -1,8 +1,9 @@
.venv/
env/
__pycache__/
*.py[cod]
.pytest_cache/
.Python
my_picocom_logfile.txt my_picocom_logfile.txt
sample_data/ *pyc
__pycache__/
*.log
*.tmp
*.bak
*.swp
*.swo
acm_9

205
README.md
View File

@ -1,205 +0,0 @@
# RFG STM32 ADC Receiver GUI
PyQtGraph-приложение для чтения свипов из последовательного порта и отображения:
- текущего свипа
- водопада по свипам
- FFT текущего свипа
- B-scan по FFT
После рефакторинга проект разделен на пакет `rfg_adc_plotter`. Старый запуск через `RFG_ADC_dataplotter.py` сохранен как совместимый wrapper.
## Структура
- `RFG_ADC_dataplotter.py` — совместимый entrypoint
- `rfg_adc_plotter/cli.py` — CLI-аргументы
- `rfg_adc_plotter/io/` — чтение порта и парсеры протоколов
- `rfg_adc_plotter/processing/` — FFT, нормировка, калибровка, поиск пиков
- `rfg_adc_plotter/state/` — runtime state и кольцевые буферы
- `rfg_adc_plotter/gui/pyqtgraph_backend.py` — GUI на PyQtGraph
- `replay_pty.py` — воспроизведение захвата через виртуальный PTY
## Зависимости
Минимально нужны:
```bash
python3 -m venv .venv
. .venv/bin/activate
pip install numpy pyqtgraph PyQt5
```
Если `pyserial` не установлен, приложение попробует открыть порт через raw TTY.
## Быстрый старт
Запуск через старый entrypoint:
```bash
.venv/bin/python RFG_ADC_dataplotter.py /dev/ttyACM0
```
Запуск напрямую через пакет:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0
```
Показать справку:
```bash
.venv/bin/python RFG_ADC_dataplotter.py --help
```
## Примеры запуска
Обычный запуск с живого порта:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --baud 115200
```
Больше истории в водопаде и ограничение FPS:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --max-sweeps 400 --max-fps 20
```
Фиксированный диапазон по оси Y:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --ylim -1000,1000
```
С включенной нормировкой `simple`:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --norm-type simple
```
Режим измерения ширины главного пика FFT:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --calibrate
```
Поиск топ-3 пиков относительно rolling median reference:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --peak_search --peak_ref_window 1.5
```
Вычитание среднего спектра по последним секундам:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --spec-mean-sec 3
```
## Протоколы ввода
ASCII-протокол по умолчанию:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0
```
Legacy binary:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --bin
```
`--bin` понимает mixed 8-байтный поток:
- `0x000A,step,ch1_i16,ch2_i16` для CH1/CH2 из `kamil_adc` (`tty:/tmp/ttyADC_data`)
- `0x001A,step,data_i16,0x0000` для логарифмического детектора
Для `0x000A` сырая кривая строится как `ch1^2 + ch2^2`, а FFT рассчитывается от комплексного сигнала `ch1 + i*ch2`.
Для `0x001A` signed `data_i16` сначала переводится в В, затем raw отображается как `V`, а FFT рассчитывается от `exp(V)`.
Параметр `--tty-range-v` применяется к обоим типам `--bin`-данных.
Logscale binary с парой `int32`:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --logscale
```
Complex binary `16-bit x2`:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --parser_16_bit_x2
```
Тестовый парсер для экспериментального `16-bit x2` потока:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --parser_test
```
Комплексный ASCII-поток `step real imag`:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --parser_complex_ascii
```
## Локальная проверка через replay_pty
Если есть лог-файл захвата, его можно воспроизвести как виртуальный последовательный порт.
В первом терминале:
```bash
.venv/bin/python replay_pty.py my_picocom_logfile.txt --pty /tmp/ttyVIRT0 --speed 1.0
```
Во втором терминале:
```bash
.venv/bin/python -m rfg_adc_plotter.main /tmp/ttyVIRT0
```
Максимально быстрый replay:
```bash
.venv/bin/python replay_pty.py my_picocom_logfile.txt --pty /tmp/ttyVIRT0 --speed 0
```
## Удаленный захват по SSH
В приложении SSH-источник не встроен. Для удаленной проверки нужно сначала получить поток или лог на локальную машину, а затем либо:
- запускать GUI напрямую на локальном PTY
- сохранять поток в файл и воспроизводить его через `replay_pty.py`
Пример команды для ручной диагностики удаленного устройства:
```bash
ssh 192.148.0.148 'ls -l /dev/ttyACM0'
```
Если на удаленной машине есть доступ к потоку, удобнее сохранять его в файл и уже этот файл гонять локально через `replay_pty.py`.
Для локального `tty`-потока из `kamil_adc` используйте:
```bash
.venv/bin/python -m rfg_adc_plotter.main /tmp/ttyADC_data --bin
```
## Проверка и тесты
Синтаксическая проверка:
```bash
python3 -m compileall RFG_ADC_dataplotter.py replay_pty.py rfg_adc_plotter tests
```
Запуск тестов:
```bash
.venv/bin/python -m unittest discover -s tests -v
```
## Замечания
- Поддерживается только PyQtGraph backend.
- `--backend mpl` оставлен только для совместимости CLI и завершится ошибкой.
- Каталоги `sample_data/` и локальные логи добавлены в `.gitignore` и не считаются частью обязательного tracked-состояния репозитория.

View File

@ -1,8 +0,0 @@
#!/usr/bin/env python3
"""Compatibility wrapper for the modularized ADC plotter."""
from rfg_adc_plotter.main import main
if __name__ == "__main__":
main()

BIN
background.npy Normal file

Binary file not shown.

BIN
calib_envelope.npy Normal file

Binary file not shown.

View File

@ -1,7 +1,16 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Replay a capture file through a pseudo-TTY for local GUI verification.""" """
Эмулятор серийного порта: воспроизводит лог-файл в цикле через PTY.
from __future__ import annotations Использование:
python3 replay_pty.py my_picocom_logfile.txt
python3 replay_pty.py my_picocom_logfile.txt --pty /tmp/ttyVIRT0
python3 replay_pty.py my_picocom_logfile.txt --speed 2.0 # в 2 раза быстрее реального
python3 replay_pty.py my_picocom_logfile.txt --speed 0 # максимально быстро
Затем в другом терминале:
python -m rfg_adc_plotter.main /tmp/ttyVIRT0
"""
import argparse import argparse
import os import os
@ -9,7 +18,7 @@ import sys
import time import time
def main() -> None: def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Воспроизводит лог-файл через PTY как виртуальный серийный порт." description="Воспроизводит лог-файл через PTY как виртуальный серийный порт."
) )
@ -34,18 +43,20 @@ def main() -> None:
"--baud", "--baud",
type=int, type=int,
default=115200, default=115200,
help="Скорость (бод) для расчета задержек (по умолчанию 115200)", help="Скорость (бод) для расчёта задержек (по умолчанию 115200)",
) )
args = parser.parse_args() args = parser.parse_args()
if not os.path.isfile(args.file): if not os.path.isfile(args.file):
sys.stderr.write(f"[error] Файл не найден: {args.file}\n") sys.stderr.write(f"[error] Файл не найден: {args.file}\n")
raise SystemExit(1) sys.exit(1)
# Открываем PTY-пару: master (мы пишем) / slave (GUI читает)
master_fd, slave_fd = os.openpty() master_fd, slave_fd = os.openpty()
slave_path = os.ttyname(slave_fd) slave_path = os.ttyname(slave_fd)
os.close(slave_fd) os.close(slave_fd) # GUI откроет slave сам по симлинку
# Симлинк с удобным именем
try: try:
os.unlink(args.pty) os.unlink(args.pty)
except FileNotFoundError: except FileNotFoundError:
@ -53,30 +64,27 @@ def main() -> None:
os.symlink(slave_path, args.pty) os.symlink(slave_path, args.pty)
print(f"PTY slave : {slave_path}") print(f"PTY slave : {slave_path}")
print(f"Симлинк : {args.pty} -> {slave_path}") print(f"Симлинк : {args.pty} {slave_path}")
print(f"Запустите : python3 -m rfg_adc_plotter.main {args.pty}") print(f"Запустите : python -m rfg_adc_plotter.main {args.pty}")
print("Ctrl+C для остановки.\n") print("Ctrl+C для остановки.\n")
# Задержка на байт: 10 бит (8N1) / скорость / множитель
if args.speed > 0: if args.speed > 0:
bytes_per_sec = args.baud / 10.0 * args.speed bytes_per_sec = args.baud / 10.0 * args.speed
delay_per_byte = 1.0 / bytes_per_sec delay_per_byte = 1.0 / bytes_per_sec
else: else:
delay_per_byte = 0.0 delay_per_byte = 0.0
chunk_size = 4096
loop = 0 loop = 0
try: try:
while True: while True:
loop += 1 loop += 1
print(f"[loop {loop}] {args.file}") print(f"[loop {loop}] {args.file}")
with open(args.file, "rb") as handle: with open(args.file, "rb") as f:
while True: for line in f:
chunk = handle.read(chunk_size) os.write(master_fd, line)
if not chunk:
break
os.write(master_fd, chunk)
if delay_per_byte > 0: if delay_per_byte > 0:
time.sleep(delay_per_byte * len(chunk)) time.sleep(delay_per_byte * len(line))
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nОстановлено.") print("\nОстановлено.")
finally: finally:

View File

@ -1,3 +0,0 @@
"""RFG ADC plotter package."""
__all__ = []

View File

@ -1,145 +0,0 @@
"""Command-line parser for the ADC plotter."""
from __future__ import annotations
import argparse
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Читает свипы из виртуального COM-порта и рисует: "
"последний свип и водопад (реалтайм)."
)
)
parser.add_argument(
"port",
help="Путь к порту, например /dev/ttyACM1 или COM3 (COM10+: \\\\.\\COM10)",
)
parser.add_argument("--baud", type=int, default=115200, help="Скорость (по умолчанию 115200)")
parser.add_argument("--max-sweeps", type=int, default=200, help="Количество видимых свипов в водопаде")
parser.add_argument("--max-fps", type=float, default=30.0, help="Лимит частоты отрисовки, кадров/с")
parser.add_argument("--cmap", default="viridis", help="Цветовая карта водопада")
parser.add_argument(
"--spec-clip",
default="2,98",
help=(
"Процентильная обрезка уровней водопада спектров, %% (min,max). "
"Напр. 2,98. 'off' — отключить"
),
)
parser.add_argument(
"--spec-mean-sec",
type=float,
default=0.0,
help=(
"Вычитание среднего по каждой частоте за последние N секунд "
"в водопаде спектров (0 — отключить)"
),
)
parser.add_argument("--title", default="ADC Sweeps", help="Заголовок окна")
parser.add_argument(
"--fancy",
action="store_true",
help="Заполнять выпавшие точки средними значениями между соседними",
)
parser.add_argument(
"--ylim",
type=str,
default=None,
help="Фиксированные Y-пределы для кривой формата min,max (например -1000,1000). По умолчанию авто",
)
parser.add_argument(
"--backend",
choices=["auto", "pg", "mpl"],
default="pg",
help="Совместимый флаг. Поддерживаются только auto и pg; mpl удален.",
)
parser.add_argument(
"--opengl",
action="store_true",
help="Включить OpenGL-ускорение для PyQtGraph. По умолчанию используется CPU-отрисовка.",
)
parser.add_argument(
"--norm-type",
choices=["projector", "simple"],
default="projector",
help="Тип нормировки: projector (по огибающим в [-1,+1]) или simple (raw/calib)",
)
parser.add_argument(
"--bin",
dest="bin_mode",
action="store_true",
help=(
"8-байтный бинарный протокол: либо legacy старт "
"0xFFFF,0xFFFF,0xFFFF,(CH<<8)|0x0A и точки step,uint32(hi16,lo16),0x000A, "
"либо mixed поток 0x000A,step,ch1_i16,ch2_i16 и 0x001A,step,data_i16,0x0000. "
"Для 0x000A: после парсинга int16 переводятся в В, "
"сырая кривая = ch1^2+ch2^2 (В^2), FFT вход = ch1+i*ch2 (В). "
"Для 0x001A: code_i16 переводится в В, raw = V, FFT вход = exp(V)"
),
)
parser.add_argument(
"--tty-range-v",
type=float,
default=5.0,
help=(
"Полный диапазон для пересчета tty int16 в напряжение ±V "
"(для --bin 0x000A CH1/CH2 и 0x001A log-detector, по умолчанию 5.0)"
),
)
parser.add_argument(
"--logscale",
action="store_true",
help=(
"Новый бинарный протокол: точка несет пару int32 (avg_1, avg_2), "
"а свип считается как |10**(avg_1*0.001) - 10**(avg_2*0.001)|"
),
)
parser.add_argument(
"--parser_16_bit_x2",
action="store_true",
help=(
"Бинарный complex-протокол c парой int16 (Re, Im): "
"старт 0xFFFF,0xFFFF,0xFFFF,(CH<<8)|0x0A; точка step,re_lo16,im_lo16,0xFFFF"
),
)
parser.add_argument(
"--parser_test",
action="store_true",
help=(
"Тестовый парсер для complex-формата 16-bit x2: "
"одиночный 0xFFFF завершает точку, серия 0xFFFF начинает новый свип"
),
)
parser.add_argument(
"--parser_complex_ascii",
action="store_true",
help=(
"ASCII-поток из трех чисел на строку: step real imag. "
"Новый свип определяется по сбросу/повтору step, FFT строится по комплексным данным"
),
)
parser.add_argument(
"--calibrate",
action="store_true",
help=(
"Режим измерения ширины главного пика FFT: рисует красные маркеры "
"границ и фона и выводит ширину пика в статус"
),
)
parser.add_argument(
"--peak_search",
action="store_true",
help=(
"Поиск топ-3 пиков на FFT относительно референса (скользящая медиана) "
"с отрисовкой bounding box и параметров пиков"
),
)
parser.add_argument(
"--peak_ref_window",
type=float,
default=1.0,
help="Ширина окна скользящей медианы для --peak_search, ГГц/м по оси FFT (по умолчанию 1.0)",
)
return parser

View File

@ -1,17 +1,13 @@
"""Shared constants for sweep parsing and visualization.""" WF_WIDTH = 1000 # максимальное число точек в ряду водопада
FFT_LEN = 2048 # длина БПФ для спектра/водопада спектров
WF_WIDTH = 1000 # Порог для инверсии сырых данных: если среднее значение свипа ниже порога —
FFT_LEN = 2048 # считаем, что сигнал «меньше нуля» и домножаем свип на -1
BACKGROUND_MEDIAN_SWEEPS = 64
SWEEP_FREQ_MIN_GHZ = 3.3
SWEEP_FREQ_MAX_GHZ = 6.3
LOG_BASE = 10.0
LOG_SCALER = 0.001
LOG_POSTSCALER = 10.0
LOG_EXP_LIMIT = 300.0
C_M_S = 299_792_458.0
DATA_INVERSION_THRESHOLD = 10.0 DATA_INVERSION_THRESHOLD = 10.0
# Параметры IFFT-спектра (временной профиль из спектра 3.2..14.3 ГГц)
# Двусторонний спектр формируется как: [нули -14.3..-3.2 | нули -3.2..+3.2 | данные +3.2..+14.3]
ZEROS_LOW = 758 # нули от -14.3 до -3.2 ГГц
ZEROS_MID = 437 # нули от -3.2 до +3.2 ГГц
SWEEP_LEN = 758 # ожидаемая длина свипа (3.2 → 14.3 ГГц)
FREQ_SPAN_GHZ = 28.6 # полная двусторонняя полоса (-14.3 .. +14.3 ГГц)
IFFT_LEN = ZEROS_LOW + ZEROS_MID + SWEEP_LEN # = 1953

View File

@ -1,5 +0,0 @@
"""GUI backends."""
from rfg_adc_plotter.gui.pyqtgraph_backend import run_pyqtgraph
__all__ = ["run_pyqtgraph"]

View File

@ -0,0 +1,353 @@
"""Matplotlib-бэкенд реалтайм-плоттера свипов."""
import sys
import threading
from queue import Queue
from typing import Optional, Tuple
import numpy as np
from rfg_adc_plotter.constants import FFT_LEN, FREQ_SPAN_GHZ, IFFT_LEN
_IFFT_T_MAX_NS = float((IFFT_LEN - 1) / (FREQ_SPAN_GHZ * 1e9) * 1e9)
from rfg_adc_plotter.io.sweep_reader import SweepReader
from rfg_adc_plotter.processing.normalizer import build_calib_envelopes
from rfg_adc_plotter.state.app_state import BACKGROUND_PATH, CALIB_ENVELOPE_PATH, AppState, format_status
from rfg_adc_plotter.state.ring_buffer import RingBuffer
from rfg_adc_plotter.types import SweepPacket
def _parse_ylim(ylim_str: Optional[str]) -> Optional[Tuple[float, float]]:
if not ylim_str:
return None
try:
y0, y1 = ylim_str.split(",")
return (float(y0), float(y1))
except Exception:
sys.stderr.write("[warn] Некорректный формат --ylim, игнорирую. Ожидалось min,max\n")
return None
def _parse_spec_clip(spec: Optional[str]) -> Optional[Tuple[float, float]]:
if not spec:
return None
s = str(spec).strip().lower()
if s in ("off", "none", "no"):
return None
try:
p0, p1 = s.replace(";", ",").split(",")
low, high = float(p0), float(p1)
if not (0.0 <= low < high <= 100.0):
return None
return (low, high)
except Exception:
return None
def _visible_levels(data: np.ndarray, axis) -> Optional[Tuple[float, float]]:
"""(vmin, vmax) по текущей видимой области imshow."""
if data.size == 0:
return None
ny, nx = data.shape[0], data.shape[1]
try:
x0, x1 = axis.get_xlim()
y0, y1 = axis.get_ylim()
except Exception:
x0, x1 = 0.0, float(nx - 1)
y0, y1 = 0.0, float(ny - 1)
xmin, xmax = sorted((float(x0), float(x1)))
ymin, ymax = sorted((float(y0), float(y1)))
ix0 = max(0, min(nx - 1, int(np.floor(xmin))))
ix1 = max(0, min(nx - 1, int(np.ceil(xmax))))
iy0 = max(0, min(ny - 1, int(np.floor(ymin))))
iy1 = max(0, min(ny - 1, int(np.ceil(ymax))))
if ix1 < ix0:
ix1 = ix0
if iy1 < iy0:
iy1 = iy0
sub = data[iy0 : iy1 + 1, ix0 : ix1 + 1]
finite = np.isfinite(sub)
if not finite.any():
return None
vals = sub[finite]
vmin = float(np.min(vals))
vmax = float(np.max(vals))
if not (np.isfinite(vmin) and np.isfinite(vmax)) or vmin == vmax:
return None
return (vmin, vmax)
def run_matplotlib(args):
try:
import matplotlib
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from matplotlib.widgets import CheckButtons, Slider
except Exception as e:
sys.stderr.write(f"[error] Нужны matplotlib и её зависимости: {e}\n")
sys.exit(1)
q: Queue[SweepPacket] = Queue(maxsize=1000)
stop_event = threading.Event()
reader = SweepReader(
args.port,
args.baud,
q,
stop_event,
fancy=bool(args.fancy),
bin_mode=bool(getattr(args, "bin_mode", False)),
)
reader.start()
max_sweeps = int(max(10, args.max_sweeps))
max_fps = max(1.0, float(args.max_fps))
interval_ms = int(1000.0 / max_fps)
spec_clip = _parse_spec_clip(getattr(args, "spec_clip", None))
spec_mean_sec = float(getattr(args, "spec_mean_sec", 0.0))
fixed_ylim = _parse_ylim(getattr(args, "ylim", None))
norm_type = str(getattr(args, "norm_type", "projector")).strip().lower()
state = AppState(norm_type=norm_type)
ring = RingBuffer(max_sweeps)
# --- Создание фигуры ---
fig, axs = plt.subplots(2, 2, figsize=(12, 8))
(ax_line, ax_img), (ax_fft, ax_spec) = axs
if hasattr(fig.canvas.manager, "set_window_title"):
fig.canvas.manager.set_window_title(args.title)
fig.subplots_adjust(wspace=0.25, hspace=0.35, left=0.07, right=0.90, top=0.92, bottom=0.08)
# Статусная строка
status_text = fig.text(0.01, 0.01, "", ha="left", va="bottom", fontsize=8, family="monospace")
# График последнего свипа
line_obj, = ax_line.plot([], [], lw=1, color="tab:blue")
line_norm_obj, = ax_line.plot([], [], lw=1, color="tab:green")
line_env_lo, = ax_line.plot([], [], lw=1, color="tab:orange", linestyle="--", alpha=0.7)
line_env_hi, = ax_line.plot([], [], lw=1, color="tab:orange", linestyle="--", alpha=0.7)
ax_line.set_title("Сырые данные", pad=1)
ax_line.set_xlabel("Частота, ГГц")
channel_text = ax_line.text(
0.98, 0.98, "", transform=ax_line.transAxes,
ha="right", va="top", fontsize=9, family="monospace",
)
if fixed_ylim is not None:
ax_line.set_ylim(fixed_ylim)
# График спектра
fft_line_obj, = ax_fft.plot([], [], lw=1)
ax_fft.set_title("FFT", pad=1)
ax_fft.set_xlabel("Время, нс")
ax_fft.set_ylabel("Мощность, дБ")
# Водопад сырых данных
img_obj = ax_img.imshow(
np.zeros((1, 1), dtype=np.float32),
aspect="auto", interpolation="nearest", origin="lower", cmap=args.cmap,
)
ax_img.set_title("Сырые данные", pad=12)
ax_img.set_ylabel("частота")
try:
ax_img.tick_params(axis="x", labelbottom=False)
except Exception:
pass
# Водопад спектров
img_fft_obj = ax_spec.imshow(
np.zeros((1, 1), dtype=np.float32),
aspect="auto", interpolation="nearest", origin="lower", cmap=args.cmap,
)
ax_spec.set_title("B-scan (дБ)", pad=12)
ax_spec.set_ylabel("Время, нс")
try:
ax_spec.tick_params(axis="x", labelbottom=False)
except Exception:
pass
# Слайдеры и чекбокс
contrast_slider = None
try:
fft_bins = ring.fft_bins
ax_smin = fig.add_axes([0.92, 0.55, 0.02, 0.35])
ax_smax = fig.add_axes([0.95, 0.55, 0.02, 0.35])
ax_sctr = fig.add_axes([0.98, 0.55, 0.02, 0.35])
ax_cb = fig.add_axes([0.92, 0.45, 0.08, 0.08])
ax_cb_file = fig.add_axes([0.92, 0.36, 0.08, 0.08])
ymin_slider = Slider(ax_smin, "Y min", 0, max(1, fft_bins - 1), valinit=0, valstep=1, orientation="vertical")
ymax_slider = Slider(ax_smax, "Y max", 0, max(1, fft_bins - 1), valinit=max(1, fft_bins - 1), valstep=1, orientation="vertical")
contrast_slider = Slider(ax_sctr, "Int max", 0, 100, valinit=100, valstep=1, orientation="vertical")
calib_cb = CheckButtons(ax_cb, ["калибровка"], [False])
calib_file_cb = CheckButtons(ax_cb_file, ["из файла"], [False])
import os as _os
if not _os.path.isfile(CALIB_ENVELOPE_PATH):
ax_cb_file.set_visible(False)
def _on_ylim_change(_val):
try:
y0 = int(min(ymin_slider.val, ymax_slider.val))
y1 = int(max(ymin_slider.val, ymax_slider.val))
ax_spec.set_ylim(y0, y1)
fig.canvas.draw_idle()
except Exception:
pass
def _on_calib_file_clicked(_v):
use_file = bool(calib_file_cb.get_status()[0])
if use_file:
ok = state.load_calib_envelope(CALIB_ENVELOPE_PATH)
if ok:
state.set_calib_mode("file")
else:
calib_file_cb.set_active(0) # снять галочку
else:
state.set_calib_mode("live")
state.set_calib_enabled(bool(calib_cb.get_status()[0]))
def _on_calib_clicked(_v):
import os as _os2
if _os2.path.isfile(CALIB_ENVELOPE_PATH):
ax_cb_file.set_visible(True)
state.set_calib_enabled(bool(calib_cb.get_status()[0]))
fig.canvas.draw_idle()
ax_btn_bg = fig.add_axes([0.92, 0.27, 0.08, 0.05])
ax_cb_bg = fig.add_axes([0.92, 0.20, 0.08, 0.06])
from matplotlib.widgets import Button as MplButton
save_bg_btn = MplButton(ax_btn_bg, "Сохр. фон")
bg_cb = CheckButtons(ax_cb_bg, ["вычет фона"], [False])
def _on_save_bg(_event):
ok = state.save_background()
if ok:
state.load_background()
fig.canvas.draw_idle()
def _on_bg_clicked(_v):
state.set_background_enabled(bool(bg_cb.get_status()[0]))
save_bg_btn.on_clicked(_on_save_bg)
bg_cb.on_clicked(_on_bg_clicked)
ymin_slider.on_changed(_on_ylim_change)
ymax_slider.on_changed(_on_ylim_change)
contrast_slider.on_changed(lambda _v: fig.canvas.draw_idle())
calib_cb.on_clicked(_on_calib_clicked)
calib_file_cb.on_clicked(_on_calib_file_clicked)
except Exception:
calib_cb = None
FREQ_MIN = 3.323
FREQ_MAX = 14.323
# --- Инициализация imshow при первом свипе ---
def _init_imshow_extents():
w = ring.width
ms = ring.max_sweeps
fb = ring.fft_bins
img_obj.set_data(np.zeros((w, ms), dtype=np.float32))
img_obj.set_extent((0, ms - 1, FREQ_MIN, FREQ_MAX))
ax_img.set_xlim(0, ms - 1)
ax_img.set_ylim(FREQ_MIN, FREQ_MAX)
img_fft_obj.set_data(np.zeros((fb, ms), dtype=np.float32))
img_fft_obj.set_extent((0, ms - 1, 0.0, _IFFT_T_MAX_NS))
ax_spec.set_xlim(0, ms - 1)
ax_spec.set_ylim(0.0, _IFFT_T_MAX_NS)
ax_fft.set_xlim(0.0, _IFFT_T_MAX_NS)
_imshow_initialized = [False]
def update(_frame):
changed = state.drain_queue(q, ring) > 0
if changed and not _imshow_initialized[0] and ring.is_ready:
_init_imshow_extents()
_imshow_initialized[0] = True
# Линейный график свипа
if state.current_sweep_raw is not None:
raw = state.current_sweep_raw
if ring.x_shared is not None and raw.size <= ring.x_shared.size:
xs = ring.x_shared[: raw.size]
else:
xs = np.arange(raw.size, dtype=np.int32)
def _norm_to_max(data):
m = float(np.nanmax(np.abs(data)))
return data / m if m > 0.0 else data
line_obj.set_data(xs, _norm_to_max(raw))
if state.calib_mode == "file" and state.calib_file_envelope is not None:
upper = state.calib_file_envelope
lower = -upper
m_env = float(np.nanmax(np.abs(upper)))
if m_env <= 0.0:
m_env = 1.0
line_env_lo.set_data(xs[: upper.size], lower / m_env)
line_env_hi.set_data(xs[: upper.size], upper / m_env)
elif state.last_calib_sweep is not None:
calib = state.last_calib_sweep
m_calib = float(np.nanmax(np.abs(calib)))
if m_calib <= 0.0:
m_calib = 1.0
lower, upper = build_calib_envelopes(calib)
line_env_lo.set_data(xs[: calib.size], lower / m_calib)
line_env_hi.set_data(xs[: calib.size], upper / m_calib)
else:
line_env_lo.set_data([], [])
line_env_hi.set_data([], [])
if state.current_sweep_norm is not None:
line_norm_obj.set_data(xs[: state.current_sweep_norm.size], _norm_to_max(state.current_sweep_norm))
else:
line_norm_obj.set_data([], [])
ax_line.set_xlim(FREQ_MIN, FREQ_MAX)
if fixed_ylim is None:
ax_line.set_ylim(-1.05, 1.05)
ax_line.set_ylabel("/ max")
# Спектр — используем уже вычисленный в ring IFFT (временной профиль)
if ring.last_fft_vals is not None and ring.fft_time_axis is not None:
fft_vals = ring.last_fft_vals
xs_fft = ring.fft_time_axis
n = min(fft_vals.size, xs_fft.size)
fft_line_obj.set_data(xs_fft[:n], fft_vals[:n])
if np.isfinite(np.nanmin(fft_vals)) and np.isfinite(np.nanmax(fft_vals)):
ax_fft.set_xlim(0, float(xs_fft[n - 1]))
ax_fft.set_ylim(float(np.nanmin(fft_vals)), float(np.nanmax(fft_vals)))
# Водопад сырых данных
if changed and ring.is_ready:
disp = ring.get_display_ring()
if ring.x_shared is not None:
n = ring.x_shared.size
disp = disp[:n, :]
img_obj.set_data(disp)
img_obj.set_extent((0, ring.max_sweeps - 1, FREQ_MIN, FREQ_MAX))
ax_img.set_ylim(FREQ_MIN, FREQ_MAX)
levels = _visible_levels(disp, ax_img)
if levels is not None:
img_obj.set_clim(vmin=levels[0], vmax=levels[1])
# Водопад спектров
if changed and ring.is_ready:
disp_fft = ring.get_display_ring_fft()
disp_fft = ring.subtract_recent_mean_fft(disp_fft, spec_mean_sec)
img_fft_obj.set_data(disp_fft)
levels = ring.compute_fft_levels(disp_fft, spec_clip)
if levels is not None:
try:
c = float(contrast_slider.val) / 100.0 if contrast_slider is not None else 1.0
except Exception:
c = 1.0
vmax_eff = levels[0] + c * (levels[1] - levels[0])
img_fft_obj.set_clim(vmin=levels[0], vmax=vmax_eff)
# Статус и подпись канала
if changed and state.current_info:
status_text.set_text(format_status(state.current_info))
channel_text.set_text(state.format_channel_label())
return (line_obj, line_norm_obj, line_env_lo, line_env_hi, img_obj, fft_line_obj, img_fft_obj, status_text, channel_text)
ani = FuncAnimation(fig, update, interval=interval_ms, blit=False)
plt.show()
stop_event.set()
reader.join(timeout=1.0)

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +0,0 @@
"""I/O helpers for serial sources and sweep parsing."""
from rfg_adc_plotter.io.serial_source import SerialChunkReader, SerialLineSource
from rfg_adc_plotter.io.sweep_reader import SweepReader
__all__ = ["SerialChunkReader", "SerialLineSource", "SweepReader"]

View File

@ -1,6 +1,4 @@
"""Serial input helpers with pyserial and raw TTY fallbacks.""" """Источники последовательного ввода: обёртки над pyserial и raw TTY."""
from __future__ import annotations
import io import io
import os import os
@ -14,13 +12,14 @@ def try_open_pyserial(path: str, baud: int, timeout: float):
except Exception: except Exception:
return None return None
try: try:
return serial.Serial(path, baudrate=baud, timeout=timeout) ser = serial.Serial(path, baudrate=baud, timeout=timeout)
return ser
except Exception: except Exception:
return None return None
class FDReader: class FDReader:
"""Buffered wrapper around a raw TTY file descriptor.""" """Простой враппер чтения строк из файлового дескриптора TTY."""
def __init__(self, fd: int): def __init__(self, fd: int):
self._fd = fd self._fd = fd
@ -34,7 +33,7 @@ class FDReader:
def readline(self) -> bytes: def readline(self) -> bytes:
return self._buf.readline() return self._buf.readline()
def close(self) -> None: def close(self):
try: try:
self._buf.close() self._buf.close()
except Exception: except Exception:
@ -42,7 +41,10 @@ class FDReader:
def open_raw_tty(path: str, baud: int) -> Optional[FDReader]: def open_raw_tty(path: str, baud: int) -> Optional[FDReader]:
"""Open a TTY without pyserial and configure it via termios.""" """Открыть TTY без pyserial и настроить порт через termios.
Возвращает FDReader или None при ошибке.
"""
try: try:
import termios import termios
import tty import tty
@ -67,14 +69,17 @@ def open_raw_tty(path: str, baud: int) -> Optional[FDReader]:
230400: getattr(termios, "B230400", None), 230400: getattr(termios, "B230400", None),
460800: getattr(termios, "B460800", None), 460800: getattr(termios, "B460800", None),
} }
speed = baud_map.get(baud) or termios.B115200 b = baud_map.get(baud) or termios.B115200
attrs[4] = speed attrs[4] = b # ispeed
attrs[5] = speed attrs[5] = b # ospeed
# VMIN=1, VTIME=0 — блокирующее чтение по байту
cc = attrs[6] cc = attrs[6]
cc[termios.VMIN] = 1 cc[termios.VMIN] = 1
cc[termios.VTIME] = 0 cc[termios.VTIME] = 0
attrs[6] = cc attrs[6] = cc
termios.tcsetattr(fd, termios.TCSANOW, attrs) termios.tcsetattr(fd, termios.TCSANOW, attrs)
except Exception: except Exception:
try: try:
@ -87,11 +92,11 @@ def open_raw_tty(path: str, baud: int) -> Optional[FDReader]:
class SerialLineSource: class SerialLineSource:
"""Unified line-oriented wrapper for pyserial and raw TTY readers.""" """Единый интерфейс для чтения строк из порта (pyserial или raw TTY)."""
def __init__(self, path: str, baud: int, timeout: float = 1.0): def __init__(self, path: str, baud: int, timeout: float = 1.0):
self._pyserial = try_open_pyserial(path, baud, timeout) self._pyserial = try_open_pyserial(path, baud, timeout)
self._fdreader: Optional[FDReader] = None self._fdreader = None
self._using = "pyserial" if self._pyserial is not None else "raw" self._using = "pyserial" if self._pyserial is not None else "raw"
if self._pyserial is None: if self._pyserial is None:
self._fdreader = open_raw_tty(path, baud) self._fdreader = open_raw_tty(path, baud)
@ -107,12 +112,13 @@ class SerialLineSource:
return self._pyserial.readline() return self._pyserial.readline()
except Exception: except Exception:
return b"" return b""
else:
try: try:
return self._fdreader.readline() # type: ignore[union-attr] return self._fdreader.readline() # type: ignore[union-attr]
except Exception: except Exception:
return b"" return b""
def close(self) -> None: def close(self):
try: try:
if self._pyserial is not None: if self._pyserial is not None:
self._pyserial.close() self._pyserial.close()
@ -123,7 +129,7 @@ class SerialLineSource:
class SerialChunkReader: class SerialChunkReader:
"""Fast non-blocking chunk reader for serial sources.""" """Быстрое неблокирующее чтение чанков из serial/raw TTY для максимального дренажа буфера."""
def __init__(self, src: SerialLineSource): def __init__(self, src: SerialLineSource):
self._src = src self._src = src
@ -145,22 +151,20 @@ class SerialChunkReader:
self._fd = None self._fd = None
def read_available(self) -> bytes: def read_available(self) -> bytes:
"""Return currently available bytes or b"" when nothing is ready.""" """Вернёт доступные байты (b"" если данных нет)."""
if self._ser is not None: if self._ser is not None:
try: try:
available = int(getattr(self._ser, "in_waiting", 0)) n = int(getattr(self._ser, "in_waiting", 0))
except Exception: except Exception:
available = 0 n = 0
if available > 0: if n > 0:
try: try:
return self._ser.read(available) return self._ser.read(n)
except Exception: except Exception:
return b"" return b""
return b"" return b""
if self._fd is None: if self._fd is None:
return b"" return b""
out = bytearray() out = bytearray()
while True: while True:
try: try:

View File

@ -1,673 +0,0 @@
"""Reusable sweep parsers and sweep assembly helpers."""
from __future__ import annotations
import math
import time
from collections import deque
from typing import List, Optional, Sequence, Set
import numpy as np
from rfg_adc_plotter.constants import DATA_INVERSION_THRESHOLD, LOG_BASE, LOG_EXP_LIMIT, LOG_POSTSCALER, LOG_SCALER
from rfg_adc_plotter.types import (
ParserEvent,
PointEvent,
SignalKind,
StartEvent,
SweepAuxCurves,
SweepInfo,
SweepPacket,
)
def u32_to_i32(value: int) -> int:
return value - 0x1_0000_0000 if (value & 0x8000_0000) else value
def u16_to_i16(value: int) -> int:
return value - 0x1_0000 if (value & 0x8000) else value
def log_value_to_linear(value: int) -> float:
exponent = max(-LOG_EXP_LIMIT, min(LOG_EXP_LIMIT, float(value) * LOG_SCALER))
return float(LOG_BASE ** exponent)
def log_pair_to_sweep(avg_1: int, avg_2: int) -> float:
value_1 = log_value_to_linear(avg_1)
value_2 = log_value_to_linear(avg_2)
return abs(value_1 - value_2) * LOG_POSTSCALER
def tty_ch_pair_to_sweep(ch_1: int, ch_2: int) -> float:
"""Reduce a raw CH1/CH2 TTY point to power-like scalar ``ch1^2 + ch2^2``."""
ch_1_i = int(ch_1)
ch_2_i = int(ch_2)
return float((ch_1_i * ch_1_i) + (ch_2_i * ch_2_i))
class AsciiSweepParser:
"""Incremental parser for ASCII sweep streams."""
def __init__(self):
self._buf = bytearray()
def feed(self, data: bytes) -> List[ParserEvent]:
if data:
self._buf += data
events: List[ParserEvent] = []
while True:
nl = self._buf.find(b"\n")
if nl == -1:
break
line = bytes(self._buf[:nl])
del self._buf[: nl + 1]
if line.endswith(b"\r"):
line = line[:-1]
if not line:
continue
if line.startswith(b"Sweep_start"):
events.append(StartEvent())
continue
parts = line.split()
if len(parts) < 3:
continue
head = parts[0].lower()
try:
if head == b"s":
if len(parts) >= 4:
ch = int(parts[1], 10)
x = int(parts[2], 10)
y = int(parts[3], 10)
else:
ch = 0
x = int(parts[1], 10)
y = int(parts[2], 10)
elif head.startswith(b"s"):
ch = int(head[1:], 10)
x = int(parts[1], 10)
y = int(parts[2], 10)
else:
continue
except Exception:
continue
events.append(PointEvent(ch=int(ch), x=int(x), y=float(y)))
return events
class ComplexAsciiSweepParser:
"""Incremental parser for ASCII ``step real imag`` streams."""
def __init__(self):
self._buf = bytearray()
self._last_step: Optional[int] = None
self._seen_points = False
def feed(self, data: bytes) -> List[ParserEvent]:
if data:
self._buf += data
events: List[ParserEvent] = []
while True:
nl = self._buf.find(b"\n")
if nl == -1:
break
line = bytes(self._buf[:nl])
del self._buf[: nl + 1]
if line.endswith(b"\r"):
line = line[:-1]
if not line:
continue
if line.lower().startswith(b"sweep_start"):
self._last_step = None
self._seen_points = False
events.append(StartEvent())
continue
parts = line.split()
if len(parts) < 3:
continue
try:
step = int(parts[0], 10)
real = float(parts[1])
imag = float(parts[2])
except Exception:
continue
if step < 0 or (not math.isfinite(real)) or (not math.isfinite(imag)):
continue
if self._seen_points and self._last_step is not None and step <= self._last_step:
events.append(StartEvent())
self._seen_points = True
self._last_step = step
events.append(
PointEvent(
ch=0,
x=step,
y=float(abs(complex(real, imag))),
aux=(float(real), float(imag)),
)
)
return events
class LegacyBinaryParser:
"""Byte-resynchronizing parser for supported 8-byte binary record formats."""
def __init__(self):
self._buf = bytearray()
self._last_step: Optional[int] = None
self._seen_points = False
self._mode: Optional[str] = None
self._current_signal_kind: Optional[SignalKind] = None
@staticmethod
def _u16_at(buf: bytearray, offset: int) -> int:
return int(buf[offset]) | (int(buf[offset + 1]) << 8)
def _emit_legacy_start(self, events: List[ParserEvent], ch: int) -> None:
self._mode = "legacy"
self._last_step = None
self._seen_points = False
self._current_signal_kind = None
events.append(StartEvent(ch=int(ch)))
def _emit_bin_start(self, events: List[ParserEvent], signal_kind: SignalKind) -> None:
self._mode = "bin"
self._last_step = None
self._seen_points = False
self._current_signal_kind = signal_kind
events.append(StartEvent(ch=0, signal_kind=signal_kind))
def _emit_tty_start(self, events: List[ParserEvent]) -> None:
self._emit_bin_start(events, signal_kind="bin_iq")
def _emit_legacy_point(self, events: List[ParserEvent], step: int, value_word_hi: int, value_word_lo: int, ch: int) -> None:
self._mode = "legacy"
self._current_signal_kind = None
if self._seen_points and self._last_step is not None and step <= self._last_step:
events.append(StartEvent(ch=int(ch)))
self._seen_points = True
self._last_step = int(step)
value = u32_to_i32((int(value_word_hi) << 16) | int(value_word_lo))
events.append(PointEvent(ch=int(ch), x=int(step), y=float(value)))
def _prepare_bin_point(self, events: List[ParserEvent], step: int, signal_kind: SignalKind) -> None:
self._mode = "bin"
if self._current_signal_kind != signal_kind:
if self._seen_points:
events.append(StartEvent(ch=0, signal_kind=signal_kind))
self._last_step = None
self._seen_points = False
self._current_signal_kind = signal_kind
if self._seen_points and self._last_step is not None and step <= self._last_step:
events.append(StartEvent(ch=0, signal_kind=signal_kind))
self._last_step = None
self._seen_points = False
self._seen_points = True
self._last_step = int(step)
def _emit_tty_point(self, events: List[ParserEvent], step: int, ch_1_word: int, ch_2_word: int) -> None:
self._prepare_bin_point(events, step=int(step), signal_kind="bin_iq")
ch_1 = u16_to_i16(int(ch_1_word))
ch_2 = u16_to_i16(int(ch_2_word))
events.append(
PointEvent(
ch=0,
x=int(step),
y=tty_ch_pair_to_sweep(ch_1, ch_2),
aux=(float(ch_1), float(ch_2)),
signal_kind="bin_iq",
)
)
def _emit_logdet_point(self, events: List[ParserEvent], step: int, value_word: int) -> None:
self._prepare_bin_point(events, step=int(step), signal_kind="bin_logdet")
value = u16_to_i16(int(value_word))
events.append(
PointEvent(
ch=0,
x=int(step),
y=float(value),
signal_kind="bin_logdet",
)
)
def feed(self, data: bytes) -> List[ParserEvent]:
if data:
self._buf += data
events: List[ParserEvent] = []
while len(self._buf) >= 8:
w0 = self._u16_at(self._buf, 0)
w1 = self._u16_at(self._buf, 2)
w2 = self._u16_at(self._buf, 4)
w3 = self._u16_at(self._buf, 6)
is_legacy_start = (w0 == 0xFFFF and w1 == 0xFFFF and w2 == 0xFFFF and self._buf[6] == 0x0A)
is_tty_start = (w0 == 0x000A and w1 == 0xFFFF and w2 == 0xFFFF and w3 == 0xFFFF)
is_legacy_point = (self._buf[6] == 0x0A and w0 != 0xFFFF)
is_tty_point = (w0 == 0x000A and w1 != 0xFFFF)
is_logdet_point = (w0 == 0x001A and w3 == 0x0000)
if is_legacy_start:
self._emit_legacy_start(events, ch=int(self._buf[7]))
del self._buf[:8]
continue
if is_tty_start:
self._emit_tty_start(events)
del self._buf[:8]
continue
if is_logdet_point:
self._emit_logdet_point(events, step=int(w1), value_word=int(w2))
del self._buf[:8]
continue
if self._mode == "legacy":
if is_legacy_point:
self._emit_legacy_point(
events,
step=int(w0),
value_word_hi=int(w1),
value_word_lo=int(w2),
ch=int(self._buf[7]),
)
del self._buf[:8]
continue
if is_tty_point and (not is_legacy_point):
self._emit_tty_point(events, step=int(w1), ch_1_word=int(w2), ch_2_word=int(w3))
del self._buf[:8]
continue
del self._buf[:1]
continue
if self._mode == "bin":
if is_tty_point:
self._emit_tty_point(events, step=int(w1), ch_1_word=int(w2), ch_2_word=int(w3))
del self._buf[:8]
continue
if is_legacy_point and (not is_tty_point):
self._emit_legacy_point(
events,
step=int(w0),
value_word_hi=int(w1),
value_word_lo=int(w2),
ch=int(self._buf[7]),
)
del self._buf[:8]
continue
del self._buf[:1]
continue
# Mode is still unknown. Accept only unambiguous point shapes to avoid
# jumping between tty and legacy interpretations on coincidental bytes.
if is_tty_point and (not is_legacy_point):
self._emit_tty_point(events, step=int(w1), ch_1_word=int(w2), ch_2_word=int(w3))
del self._buf[:8]
continue
if is_legacy_point and (not is_tty_point):
self._emit_legacy_point(
events,
step=int(w0),
value_word_hi=int(w1),
value_word_lo=int(w2),
ch=int(self._buf[7]),
)
del self._buf[:8]
continue
del self._buf[:1]
return events
class LogScaleBinaryParser32:
"""Byte-resynchronizing parser for 32-bit logscale pair records."""
def __init__(self):
self._buf = bytearray()
self._last_step: Optional[int] = None
self._seen_points = False
@staticmethod
def _u16_at(buf: bytearray, offset: int) -> int:
return int(buf[offset]) | (int(buf[offset + 1]) << 8)
def feed(self, data: bytes) -> List[ParserEvent]:
if data:
self._buf += data
events: List[ParserEvent] = []
while len(self._buf) >= 12:
words = [self._u16_at(self._buf, idx * 2) for idx in range(6)]
if words[0:5] == [0xFFFF] * 5 and (words[5] & 0x00FF) == 0x000A:
self._last_step = None
self._seen_points = False
events.append(StartEvent(ch=int((words[5] >> 8) & 0x00FF)))
del self._buf[:12]
continue
if (words[5] & 0x00FF) == 0x000A and words[0] != 0xFFFF:
ch = int((words[5] >> 8) & 0x00FF)
if self._seen_points and self._last_step is not None and words[0] <= self._last_step:
events.append(StartEvent(ch=ch))
self._seen_points = True
self._last_step = int(words[0])
avg_1 = u32_to_i32((words[1] << 16) | words[2])
avg_2 = u32_to_i32((words[3] << 16) | words[4])
events.append(
PointEvent(
ch=ch,
x=int(words[0]),
y=log_pair_to_sweep(avg_1, avg_2),
aux=(float(avg_1), float(avg_2)),
)
)
del self._buf[:12]
continue
del self._buf[:1]
return events
class LogScale16BitX2BinaryParser:
"""Byte-resynchronizing parser for 16-bit x2 logscale records."""
def __init__(self):
self._buf = bytearray()
self._current_channel = 0
self._last_step: Optional[int] = None
self._seen_points = False
@staticmethod
def _u16_at(buf: bytearray, offset: int) -> int:
return int(buf[offset]) | (int(buf[offset + 1]) << 8)
def feed(self, data: bytes) -> List[ParserEvent]:
if data:
self._buf += data
events: List[ParserEvent] = []
while len(self._buf) >= 8:
words = [self._u16_at(self._buf, idx * 2) for idx in range(4)]
if words[0:3] == [0xFFFF, 0xFFFF, 0xFFFF] and (words[3] & 0x00FF) == 0x000A:
self._current_channel = int((words[3] >> 8) & 0x00FF)
self._last_step = None
self._seen_points = False
events.append(StartEvent(ch=self._current_channel))
del self._buf[:8]
continue
if words[3] == 0xFFFF and words[0] != 0xFFFF:
if self._seen_points and self._last_step is not None and words[0] <= self._last_step:
events.append(StartEvent(ch=self._current_channel))
self._seen_points = True
self._last_step = int(words[0])
real = u16_to_i16(words[1])
imag = u16_to_i16(words[2])
events.append(
PointEvent(
ch=self._current_channel,
x=int(words[0]),
y=float(abs(complex(real, imag))),
aux=(float(real), float(imag)),
)
)
del self._buf[:8]
continue
del self._buf[:1]
return events
class ParserTestStreamParser:
"""Parser for the special test 16-bit x2 stream format."""
def __init__(self):
self._buf = bytearray()
self._buf_pos = 0
self._point_buf: list[int] = []
self._ffff_run = 0
self._current_channel = 0
self._expected_step: Optional[int] = None
self._in_sweep = False
self._local_resync = False
def _consume_point(self) -> Optional[PointEvent]:
if len(self._point_buf) != 3:
return None
step = int(self._point_buf[0])
if step <= 0:
return None
if self._expected_step is not None and step < self._expected_step:
return None
real = u16_to_i16(int(self._point_buf[1]))
imag = u16_to_i16(int(self._point_buf[2]))
self._expected_step = step + 1
return PointEvent(
ch=self._current_channel,
x=step,
y=float(abs(complex(real, imag))),
aux=(float(real), float(imag)),
)
def feed(self, data: bytes) -> List[ParserEvent]:
if data:
self._buf += data
events: List[ParserEvent] = []
while (self._buf_pos + 1) < len(self._buf):
word = int(self._buf[self._buf_pos]) | (int(self._buf[self._buf_pos + 1]) << 8)
self._buf_pos += 2
if word == 0xFFFF:
self._ffff_run += 1
continue
if self._ffff_run > 0:
bad_point_on_delim = False
if self._in_sweep and self._point_buf and not self._local_resync:
point = self._consume_point()
if point is None:
self._local_resync = True
bad_point_on_delim = True
else:
events.append(point)
self._point_buf.clear()
if self._ffff_run >= 2:
if (word & 0x00FF) == 0x000A:
self._current_channel = (word >> 8) & 0x00FF
self._in_sweep = True
self._expected_step = 1
self._local_resync = False
self._point_buf.clear()
events.append(StartEvent(ch=self._current_channel))
self._ffff_run = 0
continue
if self._in_sweep:
self._local_resync = True
self._ffff_run = 0
continue
if self._local_resync and not bad_point_on_delim:
self._local_resync = False
self._point_buf.clear()
self._ffff_run = 0
if self._in_sweep and not self._local_resync:
self._point_buf.append(word)
if len(self._point_buf) > 3:
self._point_buf.clear()
self._local_resync = True
if self._buf_pos >= 262144:
del self._buf[: self._buf_pos]
self._buf_pos = 0
if (len(self._buf) - self._buf_pos) > 1_000_000:
tail = self._buf[self._buf_pos :]
if len(tail) > 262144:
tail = tail[-262144:]
self._buf = bytearray(tail)
self._buf_pos = 0
return events
class SweepAssembler:
"""Collect parser events into sweep packets matching runtime expectations."""
def __init__(self, fancy: bool = False, apply_inversion: bool = True):
self._fancy = bool(fancy)
self._apply_inversion = bool(apply_inversion)
self._max_width = 0
self._sweep_idx = 0
self._last_sweep_ts: Optional[float] = None
self._n_valid_hist = deque()
self._xs: list[int] = []
self._ys: list[float] = []
self._aux_1: list[float] = []
self._aux_2: list[float] = []
self._cur_channel: Optional[int] = None
self._cur_signal_kind: Optional[SignalKind] = None
self._cur_channels: set[int] = set()
def _reset_current(self) -> None:
self._xs.clear()
self._ys.clear()
self._aux_1.clear()
self._aux_2.clear()
self._cur_channel = None
self._cur_signal_kind = None
self._cur_channels.clear()
def _scatter(self, xs: Sequence[int], values: Sequence[float], width: int) -> np.ndarray:
series = np.full((width,), np.nan, dtype=np.float32)
try:
idx = np.asarray(xs, dtype=np.int64)
vals = np.asarray(values, dtype=np.float32)
series[idx] = vals
except Exception:
for x, y in zip(xs, values):
xi = int(x)
if 0 <= xi < width:
series[xi] = float(y)
return series
@staticmethod
def _fill_missing(series: np.ndarray) -> None:
known = ~np.isnan(series)
if not np.any(known):
return
known_idx = np.nonzero(known)[0]
for i0, i1 in zip(known_idx[:-1], known_idx[1:]):
if i1 - i0 > 1:
avg = (series[i0] + series[i1]) * 0.5
series[i0 + 1 : i1] = avg
first_idx = int(known_idx[0])
last_idx = int(known_idx[-1])
if first_idx > 0:
series[:first_idx] = series[first_idx]
if last_idx < series.size - 1:
series[last_idx + 1 :] = series[last_idx]
def consume(self, event: ParserEvent) -> Optional[SweepPacket]:
if isinstance(event, StartEvent):
packet = self.finalize_current()
self._reset_current()
if event.ch is not None:
self._cur_channel = int(event.ch)
self._cur_signal_kind = event.signal_kind
return packet
point_ch = int(event.ch)
point_signal_kind = event.signal_kind
packet: Optional[SweepPacket] = None
if self._cur_channel is None:
self._cur_channel = point_ch
elif point_ch != self._cur_channel:
if self._xs:
# Never mix channels in a single sweep packet: otherwise
# identical step indexes can overwrite each other.
packet = self.finalize_current()
self._reset_current()
self._cur_channel = point_ch
if self._cur_signal_kind != point_signal_kind:
if self._xs:
packet = self.finalize_current()
self._reset_current()
self._cur_channel = point_ch
self._cur_signal_kind = point_signal_kind
self._cur_channels.add(point_ch)
self._xs.append(int(event.x))
self._ys.append(float(event.y))
if event.aux is not None:
self._aux_1.append(float(event.aux[0]))
self._aux_2.append(float(event.aux[1]))
return packet
def finalize_current(self) -> Optional[SweepPacket]:
if not self._xs:
return None
ch_list = sorted(self._cur_channels) if self._cur_channels else [0]
ch_primary = ch_list[0] if ch_list else 0
width = max(int(max(self._xs)) + 1, 1)
self._max_width = max(self._max_width, width)
target_width = self._max_width if self._fancy else width
sweep = self._scatter(self._xs, self._ys, target_width)
aux_curves: SweepAuxCurves = None
if self._aux_1 and self._aux_2 and len(self._aux_1) == len(self._xs):
aux_curves = (
self._scatter(self._xs, self._aux_1, target_width),
self._scatter(self._xs, self._aux_2, target_width),
)
n_valid_cur = int(np.count_nonzero(np.isfinite(sweep)))
if self._fancy:
self._fill_missing(sweep)
if aux_curves is not None:
self._fill_missing(aux_curves[0])
self._fill_missing(aux_curves[1])
if self._apply_inversion:
try:
mean_value = float(np.nanmean(sweep))
if np.isfinite(mean_value) and mean_value < DATA_INVERSION_THRESHOLD:
sweep *= -1.0
except Exception:
pass
self._sweep_idx += 1
now = time.time()
if self._last_sweep_ts is None:
dt_ms = float("nan")
else:
dt_ms = (now - self._last_sweep_ts) * 1000.0
self._last_sweep_ts = now
self._n_valid_hist.append((now, n_valid_cur))
while self._n_valid_hist and (now - self._n_valid_hist[0][0]) > 1.0:
self._n_valid_hist.popleft()
n_valid = float(sum(value for _ts, value in self._n_valid_hist) / len(self._n_valid_hist))
if n_valid_cur > 0:
vmin = float(np.nanmin(sweep))
vmax = float(np.nanmax(sweep))
mean = float(np.nanmean(sweep))
std = float(np.nanstd(sweep))
else:
vmin = vmax = mean = std = float("nan")
info: SweepInfo = {
"sweep": self._sweep_idx,
"ch": ch_primary,
"chs": ch_list,
"signal_kind": self._cur_signal_kind,
"n_valid": n_valid,
"min": vmin,
"max": vmax,
"mean": mean,
"std": std,
"dt_ms": dt_ms,
}
return (sweep, info, aux_curves)

View File

@ -1,109 +1,21 @@
"""Background sweep reader thread.""" """Фоновый поток чтения и парсинга свипов из последовательного порта."""
from __future__ import annotations
import sys import sys
import threading import threading
import time import time
from collections import deque
from queue import Full, Queue from queue import Full, Queue
from typing import Optional
import numpy as np
from rfg_adc_plotter.constants import DATA_INVERSION_THRESHOLD
from rfg_adc_plotter.io.serial_source import SerialChunkReader, SerialLineSource from rfg_adc_plotter.io.serial_source import SerialChunkReader, SerialLineSource
from rfg_adc_plotter.io.sweep_parser_core import ( from rfg_adc_plotter.types import SweepInfo, SweepPacket
AsciiSweepParser,
ComplexAsciiSweepParser,
LegacyBinaryParser,
LogScale16BitX2BinaryParser,
LogScaleBinaryParser32,
ParserTestStreamParser,
SweepAssembler,
)
from rfg_adc_plotter.types import ParserEvent, PointEvent, StartEvent, SweepPacket
_PARSER_16_BIT_X2_PROBE_BYTES = 64 * 1024
_LEGACY_STREAM_MIN_RECORDS = 32
_LEGACY_STREAM_MIN_MATCH_RATIO = 0.95
_TTY_STREAM_MIN_MATCH_RATIO = 0.60
_DEBUG_FRAME_LOG_EVERY = 10
_NO_INPUT_WARN_INTERVAL_S = 5.0
_NO_PACKET_WARN_INTERVAL_S = 5.0
_NO_PACKET_HINT_AFTER_S = 10.0
def _u16le_at(data: bytes, offset: int) -> int:
return int(data[offset]) | (int(data[offset + 1]) << 8)
def _looks_like_legacy_8byte_stream(data: bytes) -> bool:
"""Heuristically detect supported 8-byte binary streams on an arbitrary byte offset."""
buf = bytes(data)
for offset in range(8):
blocks = (len(buf) - offset) // 8
if blocks < _LEGACY_STREAM_MIN_RECORDS:
continue
min_matches = max(_LEGACY_STREAM_MIN_RECORDS, int(blocks * _LEGACY_STREAM_MIN_MATCH_RATIO))
matched_steps_legacy: list[int] = []
matched_steps_tty: list[int] = []
matched_steps_logdet: list[int] = []
for block_idx in range(blocks):
base = offset + (block_idx * 8)
if (_u16le_at(buf, base + 6) & 0x00FF) != 0x000A:
w0 = _u16le_at(buf, base)
w1 = _u16le_at(buf, base + 2)
w3 = _u16le_at(buf, base + 6)
if w0 == 0x000A and w1 != 0xFFFF:
matched_steps_tty.append(w1)
elif w0 == 0x001A and w3 == 0x0000:
matched_steps_logdet.append(w1)
continue
matched_steps_legacy.append(_u16le_at(buf, base))
if len(matched_steps_legacy) >= min_matches:
monotonic_or_reset = 0
for prev_step, next_step in zip(matched_steps_legacy, matched_steps_legacy[1:]):
if next_step == (prev_step + 1) or next_step <= prev_step:
monotonic_or_reset += 1
if monotonic_or_reset >= max(4, len(matched_steps_legacy) - 4):
return True
tty_min_matches = max(_LEGACY_STREAM_MIN_RECORDS, int(blocks * _TTY_STREAM_MIN_MATCH_RATIO))
if len(matched_steps_tty) >= tty_min_matches:
monotonic_or_reset = 0
for prev_step, next_step in zip(matched_steps_tty, matched_steps_tty[1:]):
if next_step == (prev_step + 1) or next_step <= 2:
monotonic_or_reset += 1
if monotonic_or_reset >= max(4, len(matched_steps_tty) - 4):
return True
if len(matched_steps_logdet) >= tty_min_matches:
monotonic_or_reset = 0
for prev_step, next_step in zip(matched_steps_logdet, matched_steps_logdet[1:]):
if next_step == (prev_step + 1) or next_step <= 2:
monotonic_or_reset += 1
if monotonic_or_reset >= max(4, len(matched_steps_logdet) - 4):
return True
return False
def _is_valid_parser_16_bit_x2_probe(events: list[ParserEvent]) -> bool:
"""Accept only plausible complex streams and ignore resync noise."""
point_steps: list[int] = []
for event in events:
if isinstance(event, PointEvent):
point_steps.append(int(event.x))
if len(point_steps) < 3:
return False
monotonic_or_small_reset = 0
for prev_step, next_step in zip(point_steps, point_steps[1:]):
if next_step == (prev_step + 1) or next_step <= 2:
monotonic_or_small_reset += 1
return monotonic_or_small_reset >= max(2, len(point_steps) - 3)
class SweepReader(threading.Thread): class SweepReader(threading.Thread):
"""Read a serial source in the background and emit completed sweep packets.""" """Фоновый поток: читает строки, формирует завершённые свипы и кладёт в очередь."""
def __init__( def __init__(
self, self,
@ -113,266 +25,279 @@ class SweepReader(threading.Thread):
stop_event: threading.Event, stop_event: threading.Event,
fancy: bool = False, fancy: bool = False,
bin_mode: bool = False, bin_mode: bool = False,
logscale: bool = False,
parser_16_bit_x2: bool = False,
parser_test: bool = False,
parser_complex_ascii: bool = False,
): ):
super().__init__(daemon=True) super().__init__(daemon=True)
self._port_path = port_path self._port_path = port_path
self._baud = int(baud) self._baud = baud
self._queue = out_queue self._q = out_queue
self._stop_event = stop_event self._stop = stop_event
self._src: Optional[SerialLineSource] = None
self._fancy = bool(fancy) self._fancy = bool(fancy)
self._bin_mode = bool(bin_mode) self._bin_mode = bool(bin_mode)
self._logscale = bool(logscale) self._max_width: int = 0
self._parser_16_bit_x2 = bool(parser_16_bit_x2) self._sweep_idx: int = 0
self._parser_test = bool(parser_test) self._last_sweep_ts: Optional[float] = None
self._parser_complex_ascii = bool(parser_complex_ascii) self._n_valid_hist = deque()
self._src: SerialLineSource | None = None
self._frames_read = 0
self._frames_dropped = 0
self._started_at = time.perf_counter()
def _resolve_parser_mode_label(self) -> str:
if self._parser_complex_ascii:
return "complex_ascii"
if self._parser_test:
return "parser_test_16x2"
if self._parser_16_bit_x2:
return "parser_16_bit_x2"
if self._logscale:
return "logscale_32"
if self._bin_mode:
return "legacy_8byte"
return "ascii"
def _build_parser(self):
if self._parser_complex_ascii:
return ComplexAsciiSweepParser(), SweepAssembler(fancy=self._fancy, apply_inversion=False)
if self._parser_test:
return ParserTestStreamParser(), SweepAssembler(fancy=self._fancy, apply_inversion=False)
if self._parser_16_bit_x2:
return LogScale16BitX2BinaryParser(), SweepAssembler(fancy=self._fancy, apply_inversion=False)
if self._logscale:
return LogScaleBinaryParser32(), SweepAssembler(fancy=self._fancy, apply_inversion=False)
if self._bin_mode:
return LegacyBinaryParser(), SweepAssembler(fancy=self._fancy, apply_inversion=True)
return AsciiSweepParser(), SweepAssembler(fancy=self._fancy, apply_inversion=True)
@staticmethod @staticmethod
def _consume_events(assembler: SweepAssembler, events) -> list[SweepPacket]: def _u32_to_i32(v: int) -> int:
packets: list[SweepPacket] = [] """Преобразование 32-bit слова в знаковое значение."""
for event in events: return v - 0x1_0000_0000 if (v & 0x8000_0000) else v
packet = assembler.consume(event)
if packet is not None:
packets.append(packet)
return packets
def _probe_parser_16_bit_x2(self, chunk_reader: SerialChunkReader): def _finalize_current(self, xs, ys, channels: Optional[set]):
parser = LogScale16BitX2BinaryParser() if not xs:
probe_buf = bytearray() return
probe_events: list[ParserEvent] = [] ch_list = sorted(channels) if channels else [0]
probe_started_at = time.perf_counter() ch_primary = ch_list[0] if ch_list else 0
max_x = max(xs)
width = max_x + 1
self._max_width = max(self._max_width, width)
target_width = self._max_width if self._fancy else width
while not self._stop_event.is_set() and len(probe_buf) < _PARSER_16_BIT_X2_PROBE_BYTES: sweep = np.full((target_width,), np.nan, dtype=np.float32)
data = chunk_reader.read_available()
if not data:
time.sleep(0.0005)
continue
probe_buf += data
probe_events.extend(parser.feed(data))
if _is_valid_parser_16_bit_x2_probe(probe_events):
assembler = SweepAssembler(fancy=self._fancy, apply_inversion=False)
probe_packets = self._consume_events(assembler, probe_events)
n_points = int(sum(1 for event in probe_events if isinstance(event, PointEvent)))
n_starts = int(sum(1 for event in probe_events if isinstance(event, StartEvent)))
probe_ms = (time.perf_counter() - probe_started_at) * 1000.0
sys.stderr.write(
"[info] parser_16_bit_x2 probe: bytes:%d events:%d points:%d starts:%d parser:16x2 elapsed_ms:%.1f\n"
% (
len(probe_buf),
len(probe_events),
n_points,
n_starts,
probe_ms,
)
)
return parser, assembler, probe_packets
probe_looks_legacy = bool(probe_buf) and _looks_like_legacy_8byte_stream(bytes(probe_buf))
n_points = int(sum(1 for event in probe_events if isinstance(event, PointEvent)))
n_starts = int(sum(1 for event in probe_events if isinstance(event, StartEvent)))
probe_ms = (time.perf_counter() - probe_started_at) * 1000.0
if probe_looks_legacy:
sys.stderr.write(
"[info] parser_16_bit_x2 probe: bytes:%d events:%d points:%d starts:%d parser:legacy(fallback) elapsed_ms:%.1f\n"
% (
len(probe_buf),
len(probe_events),
n_points,
n_starts,
probe_ms,
)
)
sys.stderr.write("[info] parser_16_bit_x2: fallback -> legacy\n")
parser = LegacyBinaryParser()
assembler = SweepAssembler(fancy=self._fancy, apply_inversion=True)
probe_packets = self._consume_events(assembler, parser.feed(bytes(probe_buf)))
return parser, assembler, probe_packets
sys.stderr.write(
"[warn] parser_16_bit_x2 probe inconclusive: bytes:%d events:%d points:%d starts:%d parser:16x2 elapsed_ms:%.1f\n"
% (
len(probe_buf),
len(probe_events),
n_points,
n_starts,
probe_ms,
)
)
sys.stderr.write(
"[hint] parser_16_bit_x2: if source is 8-byte tty CH1/CH2 stream (0x000A,step,ch1,ch2), try --bin\n"
)
assembler = SweepAssembler(fancy=self._fancy, apply_inversion=False)
return parser, assembler, []
def _enqueue(self, packet: SweepPacket) -> None:
dropped = False
try: try:
self._queue.put_nowait(packet) idx = np.asarray(xs, dtype=np.int64)
vals = np.asarray(ys, dtype=np.float32)
sweep[idx] = vals
except Exception:
for x, y in zip(xs, ys):
if 0 <= x < target_width:
sweep[x] = float(y)
finite_pre = np.isfinite(sweep)
n_valid_cur = int(np.count_nonzero(finite_pre))
if self._fancy:
try:
known = ~np.isnan(sweep)
if np.any(known):
known_idx = np.nonzero(known)[0]
for i0, i1 in zip(known_idx[:-1], known_idx[1:]):
if i1 - i0 > 1:
avg = (sweep[i0] + sweep[i1]) * 0.5
sweep[i0 + 1 : i1] = avg
first_idx = int(known_idx[0])
last_idx = int(known_idx[-1])
if first_idx > 0:
sweep[:first_idx] = sweep[first_idx]
if last_idx < sweep.size - 1:
sweep[last_idx + 1 :] = sweep[last_idx]
except Exception:
pass
try:
m = float(np.nanmean(sweep))
if np.isfinite(m) and m < DATA_INVERSION_THRESHOLD:
sweep *= -1.0
except Exception:
pass
self._sweep_idx += 1
if len(ch_list) > 1:
sys.stderr.write(
f"[warn] Sweep {self._sweep_idx}: изменялся номер канала: {ch_list}\n"
)
now = time.time()
if self._last_sweep_ts is None:
dt_ms = float("nan")
else:
dt_ms = (now - self._last_sweep_ts) * 1000.0
self._last_sweep_ts = now
self._n_valid_hist.append((now, n_valid_cur))
while self._n_valid_hist and (now - self._n_valid_hist[0][0]) > 1.0:
self._n_valid_hist.popleft()
if self._n_valid_hist:
n_valid = float(sum(v for _t, v in self._n_valid_hist) / len(self._n_valid_hist))
else:
n_valid = float(n_valid_cur)
if n_valid_cur > 0:
vmin = float(np.nanmin(sweep))
vmax = float(np.nanmax(sweep))
mean = float(np.nanmean(sweep))
std = float(np.nanstd(sweep))
else:
vmin = vmax = mean = std = float("nan")
info: SweepInfo = {
"sweep": self._sweep_idx,
"ch": ch_primary,
"chs": ch_list,
"n_valid": n_valid,
"min": vmin,
"max": vmax,
"mean": mean,
"std": std,
"dt_ms": dt_ms,
}
try:
self._q.put_nowait((sweep, info))
except Full: except Full:
try: try:
_ = self._queue.get_nowait() _ = self._q.get_nowait()
dropped = True
except Exception: except Exception:
pass pass
try: try:
self._queue.put_nowait(packet) self._q.put_nowait((sweep, info))
except Exception: except Exception:
pass pass
if dropped:
self._frames_dropped += 1
self._frames_read += 1 def _run_ascii_stream(self, chunk_reader: SerialChunkReader):
if self._frames_read % _DEBUG_FRAME_LOG_EVERY == 0: xs: list[int] = []
sweep, info, _aux = packet ys: list[int] = []
cur_channel: Optional[int] = None
cur_channels: set[int] = set()
buf = bytearray()
while not self._stop.is_set():
data = chunk_reader.read_available()
if data:
buf += data
else:
time.sleep(0.0005)
continue
while True:
nl = buf.find(b"\n")
if nl == -1:
break
line = bytes(buf[:nl])
del buf[: nl + 1]
if line.endswith(b"\r"):
line = line[:-1]
if not line:
continue
if line.startswith(b"Sweep_start"):
self._finalize_current(xs, ys, cur_channels)
xs.clear()
ys.clear()
cur_channel = None
cur_channels.clear()
continue
if len(line) >= 3:
parts = line.split()
if len(parts) >= 3 and (parts[0].lower() == b"s" or parts[0].lower().startswith(b"s")):
try: try:
queue_size = self._queue.qsize() if parts[0].lower() == b"s":
if len(parts) >= 4:
ch = int(parts[1], 10)
x = int(parts[2], 10)
y = int(parts[3], 10)
else:
ch = 0
x = int(parts[1], 10)
y = int(parts[2], 10)
else:
ch = int(parts[0][1:], 10)
x = int(parts[1], 10)
y = int(parts[2], 10)
except Exception: except Exception:
queue_size = -1 continue
elapsed_s = max(time.perf_counter() - self._started_at, 1e-9) if cur_channel is None:
frames_per_sec = float(self._frames_read) / elapsed_s cur_channel = ch
sweep_idx = info.get("sweep") if isinstance(info, dict) else None cur_channels.add(ch)
channel = info.get("ch") if isinstance(info, dict) else None xs.append(x)
sys.stderr.write( ys.append(y)
"[debug] reader frames:%d rate:%.2f/s last_sweep:%s ch:%s width:%d queue:%d dropped:%d\n"
% ( if len(buf) > 1_000_000:
self._frames_read, del buf[:-262144]
frames_per_sec,
str(sweep_idx), self._finalize_current(xs, ys, cur_channels)
str(channel),
int(getattr(sweep, "size", 0)), def _run_binary_stream(self, chunk_reader: SerialChunkReader):
int(queue_size), xs: list[int] = []
self._frames_dropped, ys: list[int] = []
) cur_channel: Optional[int] = None
) cur_channels: set[int] = set()
words = deque()
buf = bytearray()
while not self._stop.is_set():
data = chunk_reader.read_available()
if data:
buf += data
else:
time.sleep(0.0005)
continue
usable = len(buf) & ~1
if usable == 0:
continue
i = 0
while i < usable:
w = int(buf[i]) | (int(buf[i + 1]) << 8)
words.append(w)
i += 2
# Бинарный протокол:
# старт свипа (актуальный): 0xFFFF, 0xFFFF, 0xFFFF, (ch<<8)|0x0A
# старт свипа (legacy): 0xFFFF, 0xFFFF, channel, 0x0A0A
# точка: step, value_hi, value_lo, 0x000A
while len(words) >= 4:
w0 = int(words[0])
w1 = int(words[1])
w2 = int(words[2])
w3 = int(words[3])
if w0 == 0xFFFF and w1 == 0xFFFF and w2 == 0xFFFF and (w3 & 0x00FF) == 0x000A:
self._finalize_current(xs, ys, cur_channels)
xs.clear()
ys.clear()
cur_channels.clear()
cur_channel = (w3 >> 8) & 0x00FF
cur_channels.add(cur_channel)
for _ in range(4):
words.popleft()
continue
if w0 == 0xFFFF and w1 == 0xFFFF and w3 == 0x0A0A:
self._finalize_current(xs, ys, cur_channels)
xs.clear()
ys.clear()
cur_channels.clear()
cur_channel = w2
cur_channels.add(cur_channel)
for _ in range(4):
words.popleft()
continue
if w3 == 0x000A:
if cur_channel is not None:
cur_channels.add(cur_channel)
xs.append(w0)
value_u32 = (w1 << 16) | w2
ys.append(self._u32_to_i32(value_u32))
for _ in range(4):
words.popleft()
continue
# Поток может начаться с середины пакета; сдвигаемся по слову до ресинхронизации.
words.popleft()
del buf[:usable]
if len(buf) > 1_000_000:
del buf[:-262144]
self._finalize_current(xs, ys, cur_channels)
def run(self):
def run(self) -> None:
try: try:
self._src = SerialLineSource(self._port_path, self._baud, timeout=1.0) self._src = SerialLineSource(self._port_path, self._baud, timeout=1.0)
queue_cap = int(getattr(self._queue, "maxsize", -1))
sys.stderr.write(f"[info] Открыл порт {self._port_path} ({self._src._using})\n") sys.stderr.write(f"[info] Открыл порт {self._port_path} ({self._src._using})\n")
sys.stderr.write( except Exception as e:
"[info] reader start: parser:%s fancy:%d queue_max:%d source:%s\n" sys.stderr.write(f"[error] {e}\n")
% (
self._resolve_parser_mode_label(),
int(self._fancy),
queue_cap,
getattr(self._src, "_using", "unknown"),
)
)
except Exception as exc:
sys.stderr.write(f"[error] {exc}\n")
return return
try: try:
chunk_reader = SerialChunkReader(self._src) chunk_reader = SerialChunkReader(self._src)
if self._parser_16_bit_x2: if self._bin_mode:
parser, assembler, pending_packets = self._probe_parser_16_bit_x2(chunk_reader) self._run_binary_stream(chunk_reader)
else: else:
parser, assembler = self._build_parser() self._run_ascii_stream(chunk_reader)
pending_packets = []
for packet in pending_packets:
self._enqueue(packet)
loop_started_at = time.perf_counter()
last_input_at = loop_started_at
last_packet_at = loop_started_at if self._frames_read > 0 else loop_started_at
last_no_input_warn_at = loop_started_at
last_no_packet_warn_at = loop_started_at
parser_hint_emitted = False
while not self._stop_event.is_set():
data = chunk_reader.read_available()
now_s = time.perf_counter()
if not data:
input_idle_s = now_s - last_input_at
if (
input_idle_s >= _NO_INPUT_WARN_INTERVAL_S
and (now_s - last_no_input_warn_at) >= _NO_INPUT_WARN_INTERVAL_S
):
sys.stderr.write(
"[warn] reader no input bytes for %.1fs on %s (parser:%s)\n"
% (
input_idle_s,
self._port_path,
self._resolve_parser_mode_label(),
)
)
last_no_input_warn_at = now_s
packets_idle_s = now_s - last_packet_at
if (
packets_idle_s >= _NO_PACKET_WARN_INTERVAL_S
and (now_s - last_no_packet_warn_at) >= _NO_PACKET_WARN_INTERVAL_S
):
try:
queue_size = self._queue.qsize()
except Exception:
queue_size = -1
sys.stderr.write(
"[warn] reader no sweep packets for %.1fs (input_idle:%.1fs queue:%d parser:%s)\n"
% (
packets_idle_s,
input_idle_s,
int(queue_size),
self._resolve_parser_mode_label(),
)
)
last_no_packet_warn_at = now_s
if (
self._parser_16_bit_x2
and (not parser_hint_emitted)
and (now_s - self._started_at) >= _NO_PACKET_HINT_AFTER_S
):
sys.stderr.write(
"[hint] parser_16_bit_x2 still has no sweeps; if source is tty CH1/CH2, rerun with --bin\n"
)
parser_hint_emitted = True
time.sleep(0.0005)
continue
last_input_at = now_s
packets = self._consume_events(assembler, parser.feed(data))
if packets:
last_packet_at = now_s
for packet in packets:
self._enqueue(packet)
packet = assembler.finalize_current()
if packet is not None:
self._enqueue(packet)
finally: finally:
try: try:
if self._src is not None: if self._src is not None:

113
rfg_adc_plotter/main.py Normal file → Executable file
View File

@ -1,25 +1,116 @@
"""Main entrypoint for the modularized ADC plotter.""" #!/usr/bin/env python3
"""
Реалтайм-плоттер для свипов из виртуального COM-порта.
from __future__ import annotations Формат строк:
- "Sweep_start" — начало нового свипа (предыдущий считается завершённым)
- "s CH X Y" — точка (номер канала, индекс X, значение Y), все целые со знаком
Отрисовываются четыре графика:
- Сырые данные: последний полученный свип (Y vs X)
- Водопад сырых данных: последние N свипов
- FFT текущего свипа
- B-scan: водопад FFT-строк
Зависимости: numpy. PySerial опционален — при его отсутствии
используется сырой доступ к TTY через termios.
GUI: matplotlib (совместимый) или pyqtgraph (быстрый).
"""
import argparse
import sys import sys
from rfg_adc_plotter.cli import build_parser
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Читает свипы из виртуального COM-порта и рисует: "
"последний свип и водопад (реалтайм)."
)
)
parser.add_argument(
"port",
help="Путь к порту, например /dev/ttyACM1 или COM3 (COM10+: \\\\.\\COM10)",
)
parser.add_argument("--baud", type=int, default=115200, help="Скорость (по умолчанию 115200)")
parser.add_argument("--max-sweeps", type=int, default=200, help="Количество видимых свипов в водопаде")
parser.add_argument("--max-fps", type=float, default=30.0, help="Лимит частоты отрисовки, кадров/с")
parser.add_argument("--cmap", default="viridis", help="Цветовая карта водопада")
parser.add_argument(
"--spec-clip",
default="2,98",
help=(
"Процентильная обрезка уровней водопада спектров, %% (min,max). "
"Напр. 2,98. 'off' — отключить"
),
)
parser.add_argument(
"--spec-mean-sec",
type=float,
default=0.0,
help=(
"Вычитание среднего по каждой частоте за последние N секунд "
"в водопаде спектров (0 — отключить)"
),
)
parser.add_argument("--title", default="ADC Sweeps", help="Заголовок окна")
parser.add_argument(
"--fancy",
action="store_true",
help="Заполнять выпавшие точки средними значениями между соседними",
)
parser.add_argument(
"--ylim",
type=str,
default=None,
help="Фиксированные Y-пределы для кривой формата min,max (например -1000,1000). По умолчанию авто",
)
parser.add_argument(
"--backend",
choices=["auto", "pg", "mpl"],
default="auto",
help="Графический бэкенд: pyqtgraph (pg) — быстрее; matplotlib (mpl) — совместимый. По умолчанию auto",
)
parser.add_argument(
"--norm-type",
choices=["projector", "simple"],
default="projector",
help="Тип нормировки: projector (по огибающим в [-1000,+1000]) или simple (raw/calib)",
)
parser.add_argument(
"--bin",
dest="bin_mode",
action="store_true",
help=(
"Бинарный протокол: старт свипа 0xFFFF,0xFFFF,0xFFFF,(CH<<8)|0x0A; "
"точки step,uint32(hi16,lo16),0x000A"
),
)
return parser
def main() -> None: def main():
args = build_parser().parse_args() args = build_parser().parse_args()
if args.backend == "mpl":
sys.stderr.write("[error] Matplotlib backend removed. Use --backend pg or --backend auto.\n")
raise SystemExit(2)
if args.backend == "pg":
from rfg_adc_plotter.gui.pyqtgraph_backend import run_pyqtgraph from rfg_adc_plotter.gui.pyqtgraph_backend import run_pyqtgraph
try: try:
run_pyqtgraph(args) run_pyqtgraph(args)
except Exception as exc: except Exception as e:
sys.stderr.write(f"[error] PyQtGraph бэкенд недоступен: {exc}\n") sys.stderr.write(f"[error] PyQtGraph бэкенд недоступен: {e}\n")
raise SystemExit(1) from exc sys.exit(1)
return
if args.backend == "auto":
try:
from rfg_adc_plotter.gui.pyqtgraph_backend import run_pyqtgraph
run_pyqtgraph(args)
return
except Exception:
pass # Откатываемся на matplotlib
from rfg_adc_plotter.gui.matplotlib_backend import run_matplotlib
run_matplotlib(args)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,79 +0,0 @@
"""Pure sweep-processing helpers."""
from rfg_adc_plotter.processing.background import (
load_fft_background,
save_fft_background,
subtract_fft_background,
validate_fft_background,
)
from rfg_adc_plotter.processing.calibration import (
build_calib_envelope,
build_complex_calibration_curve,
calibrate_freqs,
get_calibration_base,
get_calibration_coeffs,
load_calib_envelope,
load_complex_calibration,
recalculate_calibration_c,
save_calib_envelope,
save_complex_calibration,
set_calibration_base_value,
)
from rfg_adc_plotter.processing.fft import (
compute_distance_axis,
compute_fft_complex_row,
compute_fft_mag_row,
compute_fft_row,
fft_mag_to_db,
)
from rfg_adc_plotter.processing.formatting import (
compute_auto_ylim,
format_status_kv,
parse_spec_clip,
)
from rfg_adc_plotter.processing.normalization import (
build_calib_envelopes,
fit_complex_calibration_to_width,
normalize_by_complex_calibration,
normalize_by_envelope,
normalize_by_calib,
)
from rfg_adc_plotter.processing.peaks import (
find_peak_width_markers,
find_top_peaks_over_ref,
rolling_median_ref,
)
__all__ = [
"build_calib_envelopes",
"build_calib_envelope",
"build_complex_calibration_curve",
"calibrate_freqs",
"compute_auto_ylim",
"compute_distance_axis",
"compute_fft_complex_row",
"compute_fft_mag_row",
"compute_fft_row",
"fft_mag_to_db",
"find_peak_width_markers",
"find_top_peaks_over_ref",
"format_status_kv",
"get_calibration_base",
"get_calibration_coeffs",
"load_calib_envelope",
"load_complex_calibration",
"load_fft_background",
"fit_complex_calibration_to_width",
"normalize_by_complex_calibration",
"normalize_by_envelope",
"normalize_by_calib",
"parse_spec_clip",
"recalculate_calibration_c",
"rolling_median_ref",
"save_calib_envelope",
"save_complex_calibration",
"save_fft_background",
"set_calibration_base_value",
"subtract_fft_background",
"validate_fft_background",
]

View File

@ -1,66 +0,0 @@
"""Helpers for persisted FFT background profiles."""
from __future__ import annotations
from pathlib import Path
import numpy as np
def validate_fft_background(background: np.ndarray) -> np.ndarray:
"""Validate a saved FFT background payload."""
values = np.asarray(background)
if values.ndim != 1:
raise ValueError("FFT background must be a 1D array")
if not np.issubdtype(values.dtype, np.number):
raise ValueError("FFT background must be numeric")
values = np.asarray(values, dtype=np.float32).reshape(-1)
if values.size == 0:
raise ValueError("FFT background is empty")
return values
def _normalize_background_path(path: str | Path) -> Path:
out = Path(path).expanduser()
if out.suffix.lower() != ".npy":
out = out.with_suffix(".npy")
return out
def save_fft_background(path: str | Path, background: np.ndarray) -> str:
"""Persist an FFT background profile as a .npy file."""
normalized_path = _normalize_background_path(path)
values = validate_fft_background(background)
np.save(normalized_path, values.astype(np.float32, copy=False))
return str(normalized_path)
def load_fft_background(path: str | Path) -> np.ndarray:
"""Load and validate an FFT background profile from a .npy file."""
normalized_path = _normalize_background_path(path)
loaded = np.load(normalized_path, allow_pickle=False)
return validate_fft_background(loaded)
def subtract_fft_background(signal_mag: np.ndarray, background_mag: np.ndarray) -> np.ndarray:
"""Subtract a background profile from FFT magnitudes in linear amplitude."""
signal = np.asarray(signal_mag, dtype=np.float32)
background = validate_fft_background(background_mag)
if signal.ndim == 1:
if signal.size != background.size:
raise ValueError("FFT background size does not match signal size")
valid = np.isfinite(signal) & np.isfinite(background)
out = np.full_like(signal, np.nan, dtype=np.float32)
if np.any(valid):
out[valid] = np.maximum(signal[valid] - background[valid], 0.0)
return out
if signal.ndim == 2:
if signal.shape[0] != background.size:
raise ValueError("FFT background size does not match signal rows")
background_2d = background[:, None]
valid = np.isfinite(signal) & np.isfinite(background_2d)
diff = signal - background_2d
return np.where(valid, np.maximum(diff, 0.0), np.nan).astype(np.float32, copy=False)
raise ValueError("FFT background subtraction supports only 1D or 2D signals")

View File

@ -1,169 +0,0 @@
"""Frequency-axis calibration helpers."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Mapping
import numpy as np
from rfg_adc_plotter.constants import SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ
from rfg_adc_plotter.processing.normalization import build_calib_envelopes
from rfg_adc_plotter.types import SweepData
def recalculate_calibration_c(
base_coeffs: np.ndarray,
f_min: float = SWEEP_FREQ_MIN_GHZ,
f_max: float = SWEEP_FREQ_MAX_GHZ,
) -> np.ndarray:
"""Recalculate coefficients while preserving sweep edges."""
coeffs = np.asarray(base_coeffs, dtype=np.float64).reshape(-1)
if coeffs.size < 3:
out = np.zeros((3,), dtype=np.float64)
out[: coeffs.size] = coeffs
coeffs = out
c0, c1, c2 = float(coeffs[0]), float(coeffs[1]), float(coeffs[2])
x0 = float(f_min)
x1 = float(f_max)
y0 = c0 + c1 * x0 + c2 * (x0 ** 2)
y1 = c0 + c1 * x1 + c2 * (x1 ** 2)
if not (np.isfinite(y0) and np.isfinite(y1)) or y1 == y0:
return np.asarray([c0, c1, c2], dtype=np.float64)
scale = (x1 - x0) / (y1 - y0)
shift = x0 - scale * y0
return np.asarray(
[
shift + scale * c0,
scale * c1,
scale * c2,
],
dtype=np.float64,
)
CALIBRATION_C_BASE = np.asarray([0.0, 1.0, 0.025], dtype=np.float64)
CALIBRATION_C = recalculate_calibration_c(CALIBRATION_C_BASE)
def get_calibration_base() -> np.ndarray:
return np.asarray(CALIBRATION_C_BASE, dtype=np.float64).copy()
def get_calibration_coeffs() -> np.ndarray:
return np.asarray(CALIBRATION_C, dtype=np.float64).copy()
def set_calibration_base_value(index: int, value: float) -> np.ndarray:
"""Update one base coefficient and recalculate the working coefficients."""
global CALIBRATION_C
CALIBRATION_C_BASE[int(index)] = float(value)
CALIBRATION_C = recalculate_calibration_c(CALIBRATION_C_BASE)
return get_calibration_coeffs()
def calibrate_freqs(sweep: Mapping[str, Any]) -> SweepData:
"""Return a sweep copy with calibrated and resampled frequency axis."""
freqs = np.asarray(sweep["F"], dtype=np.float64).copy()
values_in = np.asarray(sweep["I"]).reshape(-1)
values = np.asarray(
values_in,
dtype=np.complex128 if np.iscomplexobj(values_in) else np.float64,
).copy()
coeffs = np.asarray(CALIBRATION_C, dtype=np.float64)
if freqs.size > 0:
freqs = coeffs[0] + coeffs[1] * freqs + coeffs[2] * (freqs * freqs)
if freqs.size >= 2:
freqs_cal = np.linspace(float(freqs[0]), float(freqs[-1]), freqs.size, dtype=np.float64)
if np.iscomplexobj(values):
values_real = np.interp(freqs_cal, freqs, values.real.astype(np.float64, copy=False))
values_imag = np.interp(freqs_cal, freqs, values.imag.astype(np.float64, copy=False))
values_cal = (values_real + (1j * values_imag)).astype(np.complex64)
else:
values_cal = np.interp(freqs_cal, freqs, values).astype(np.float64)
else:
freqs_cal = freqs.copy()
values_cal = values.copy()
return {
"F": freqs_cal,
"I": values_cal,
}
def build_calib_envelope(sweep: np.ndarray) -> np.ndarray:
"""Build the active calibration envelope from a raw sweep."""
values = np.asarray(sweep, dtype=np.float32).reshape(-1)
if values.size == 0:
raise ValueError("Calibration sweep is empty")
_, upper = build_calib_envelopes(values)
return np.asarray(upper, dtype=np.float32)
def build_complex_calibration_curve(ch1: np.ndarray, ch2: np.ndarray) -> np.ndarray:
"""Build a complex calibration curve as ``ch1 + 1j*ch2``."""
ch1_arr = np.asarray(ch1, dtype=np.float32).reshape(-1)
ch2_arr = np.asarray(ch2, dtype=np.float32).reshape(-1)
width = min(ch1_arr.size, ch2_arr.size)
if width <= 0:
raise ValueError("Complex calibration source is empty")
curve = ch1_arr[:width].astype(np.complex64) + (1j * ch2_arr[:width].astype(np.complex64))
return validate_complex_calibration_curve(curve)
def validate_calib_envelope(envelope: np.ndarray) -> np.ndarray:
"""Validate a saved calibration envelope payload."""
values = np.asarray(envelope, dtype=np.float32).reshape(-1)
if values.size == 0:
raise ValueError("Calibration envelope is empty")
if not np.issubdtype(values.dtype, np.number):
raise ValueError("Calibration envelope must be numeric")
return values
def validate_complex_calibration_curve(curve: np.ndarray) -> np.ndarray:
"""Validate a saved complex calibration payload."""
values = np.asarray(curve).reshape(-1)
if values.size == 0:
raise ValueError("Complex calibration curve is empty")
if not np.issubdtype(values.dtype, np.number):
raise ValueError("Complex calibration curve must be numeric")
return np.asarray(values, dtype=np.complex64)
def _normalize_calib_path(path: str | Path) -> Path:
out = Path(path).expanduser()
if out.suffix.lower() != ".npy":
out = out.with_suffix(".npy")
return out
def save_calib_envelope(path: str | Path, envelope: np.ndarray) -> str:
"""Persist a calibration envelope as a .npy file and return the final path."""
normalized_path = _normalize_calib_path(path)
values = validate_calib_envelope(envelope)
np.save(normalized_path, values.astype(np.float32, copy=False))
return str(normalized_path)
def load_calib_envelope(path: str | Path) -> np.ndarray:
"""Load and validate a calibration envelope from a .npy file."""
normalized_path = _normalize_calib_path(path)
loaded = np.load(normalized_path, allow_pickle=False)
return validate_calib_envelope(loaded)
def save_complex_calibration(path: str | Path, curve: np.ndarray) -> str:
"""Persist a complex calibration curve as a .npy file and return the final path."""
normalized_path = _normalize_calib_path(path)
values = validate_complex_calibration_curve(curve)
np.save(normalized_path, values.astype(np.complex64, copy=False))
return str(normalized_path)
def load_complex_calibration(path: str | Path) -> np.ndarray:
"""Load and validate a complex calibration curve from a .npy file."""
normalized_path = _normalize_calib_path(path)
loaded = np.load(normalized_path, allow_pickle=False)
return validate_complex_calibration_curve(loaded)

View File

@ -1,511 +0,0 @@
"""FFT helpers for line and waterfall views."""
from __future__ import annotations
from typing import Optional, Tuple
import numpy as np
from rfg_adc_plotter.constants import C_M_S, FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ
def _finite_freq_bounds(freqs: Optional[np.ndarray]) -> Optional[Tuple[float, float]]:
"""Return finite frequency bounds for the current working segment."""
if freqs is None:
return None
freq_arr = np.asarray(freqs, dtype=np.float64).reshape(-1)
finite = freq_arr[np.isfinite(freq_arr)]
if finite.size < 2:
return None
f_min = float(np.min(finite))
f_max = float(np.max(finite))
if not np.isfinite(f_min) or not np.isfinite(f_max) or f_max <= f_min:
return None
return f_min, f_max
def _coerce_sweep_array(sweep: np.ndarray) -> np.ndarray:
values = np.asarray(sweep).reshape(-1)
if np.iscomplexobj(values):
return np.asarray(values, dtype=np.complex64)
return np.asarray(values, dtype=np.float32)
def _interp_signal(x_uniform: np.ndarray, x_known: np.ndarray, y_known: np.ndarray) -> np.ndarray:
if np.iscomplexobj(y_known):
real = np.interp(x_uniform, x_known, np.asarray(y_known.real, dtype=np.float64))
imag = np.interp(x_uniform, x_known, np.asarray(y_known.imag, dtype=np.float64))
return (real + (1j * imag)).astype(np.complex64)
return np.interp(x_uniform, x_known, np.asarray(y_known, dtype=np.float64)).astype(np.float32)
def _fit_complex_bins(values: np.ndarray, bins: int) -> np.ndarray:
arr = np.asarray(values, dtype=np.complex64).reshape(-1)
if bins <= 0:
return np.zeros((0,), dtype=np.complex64)
if arr.size == bins:
return arr
out = np.full((bins,), np.nan + 0j, dtype=np.complex64)
take = min(arr.size, bins)
out[:take] = arr[:take]
return out
def _extract_positive_exact_band(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
) -> Optional[Tuple[np.ndarray, np.ndarray, float, float]]:
"""Return sorted positive band data and exact-grid parameters."""
if freqs is None:
return None
sweep_arr = _coerce_sweep_array(sweep)
freq_arr = np.asarray(freqs, dtype=np.float64).reshape(-1)
take = min(int(sweep_arr.size), int(freq_arr.size))
if take <= 1:
return None
sweep_seg = sweep_arr[:take]
freq_seg = freq_arr[:take]
valid = np.isfinite(freq_seg) & np.isfinite(sweep_seg) & (freq_seg > 0.0)
if int(np.count_nonzero(valid)) < 2:
return None
freq_band = np.asarray(freq_seg[valid], dtype=np.float64)
sweep_band = np.asarray(sweep_seg[valid])
order = np.argsort(freq_band, kind="mergesort")
freq_band = freq_band[order]
sweep_band = sweep_band[order]
n_band = int(freq_band.size)
if n_band <= 1:
return None
f_min = float(freq_band[0])
f_max = float(freq_band[-1])
if (not np.isfinite(f_min)) or (not np.isfinite(f_max)) or f_max <= f_min:
return None
df_ghz = float((f_max - f_min) / max(1, n_band - 1))
if (not np.isfinite(df_ghz)) or df_ghz <= 0.0:
return None
return freq_band, sweep_band, f_max, df_ghz
def _positive_exact_shift_size(f_max: float, df_ghz: float) -> int:
if (not np.isfinite(f_max)) or (not np.isfinite(df_ghz)) or f_max <= 0.0 or df_ghz <= 0.0:
return 0
return int(np.arange(-f_max, f_max + (0.5 * df_ghz), df_ghz, dtype=np.float64).size)
def _resolve_positive_exact_band_size(
f_min: float,
f_max: float,
n_band: int,
max_shift_len: Optional[int],
) -> int:
if n_band <= 2:
return max(2, int(n_band))
if max_shift_len is None:
return int(n_band)
limit = int(max_shift_len)
if limit <= 1:
return max(2, int(n_band))
span = float(f_max - f_min)
if (not np.isfinite(span)) or span <= 0.0:
return int(n_band)
df_current = float(span / max(1, int(n_band) - 1))
if _positive_exact_shift_size(f_max, df_current) <= limit:
return int(n_band)
denom = max(2.0 * f_max, 1e-12)
approx = int(np.floor(1.0 + ((float(limit - 1) * span) / denom)))
target = min(int(n_band), max(2, approx))
while target > 2:
df_try = float(span / max(1, target - 1))
if _positive_exact_shift_size(f_max, df_try) <= limit:
break
target -= 1
return max(2, target)
def _normalize_positive_exact_band(
freq_band: np.ndarray,
sweep_band: np.ndarray,
*,
max_shift_len: Optional[int] = None,
) -> Optional[Tuple[np.ndarray, np.ndarray, float, float]]:
freq_arr = np.asarray(freq_band, dtype=np.float64).reshape(-1)
sweep_arr = np.asarray(sweep_band).reshape(-1)
width = min(int(freq_arr.size), int(sweep_arr.size))
if width <= 1:
return None
freq_arr = freq_arr[:width]
sweep_arr = sweep_arr[:width]
f_min = float(freq_arr[0])
f_max = float(freq_arr[-1])
if (not np.isfinite(f_min)) or (not np.isfinite(f_max)) or f_max <= f_min:
return None
target_band = _resolve_positive_exact_band_size(f_min, f_max, int(freq_arr.size), max_shift_len)
if target_band < int(freq_arr.size):
target_freqs = np.linspace(f_min, f_max, target_band, dtype=np.float64)
target_sweep = _interp_signal(target_freqs, freq_arr, sweep_arr)
freq_arr = target_freqs
sweep_arr = np.asarray(target_sweep).reshape(-1)
n_band = int(freq_arr.size)
if n_band <= 1:
return None
df_ghz = float((f_max - f_min) / max(1, n_band - 1))
if (not np.isfinite(df_ghz)) or df_ghz <= 0.0:
return None
return freq_arr, sweep_arr, f_max, df_ghz
def _resolve_positive_only_exact_geometry(
freqs: Optional[np.ndarray],
*,
max_shift_len: Optional[int] = None,
) -> Optional[Tuple[int, float]]:
"""Return (N_shift, df_hz) for the exact centered positive-only mode."""
if freqs is None:
return None
freq_arr = np.asarray(freqs, dtype=np.float64).reshape(-1)
finite = np.asarray(freq_arr[np.isfinite(freq_arr) & (freq_arr > 0.0)], dtype=np.float64)
if finite.size < 2:
return None
finite.sort(kind="mergesort")
f_min = float(finite[0])
f_max = float(finite[-1])
if (not np.isfinite(f_min)) or (not np.isfinite(f_max)) or f_max <= f_min:
return None
n_band = int(finite.size)
target_band = _resolve_positive_exact_band_size(f_min, f_max, n_band, max_shift_len)
n_band = max(2, min(n_band, target_band))
df_ghz = float((f_max - f_min) / max(1, n_band - 1))
if (not np.isfinite(df_ghz)) or df_ghz <= 0.0:
return None
n_shift = _positive_exact_shift_size(f_max, df_ghz)
if n_shift <= 1:
return None
return int(n_shift), float(df_ghz * 1e9)
def prepare_fft_segment(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
fft_len: int = FFT_LEN,
) -> Optional[Tuple[np.ndarray, int]]:
"""Prepare a sweep segment for FFT on a uniform frequency grid."""
take_fft = min(int(sweep.size), int(fft_len))
if take_fft <= 0:
return None
sweep_arr = _coerce_sweep_array(sweep)
sweep_seg = sweep_arr[:take_fft]
fallback_dtype = np.complex64 if np.iscomplexobj(sweep_seg) else np.float32
fallback = np.nan_to_num(sweep_seg, nan=0.0).astype(fallback_dtype, copy=False)
if freqs is None:
return fallback, take_fft
freq_arr = np.asarray(freqs)
if freq_arr.size < take_fft:
return fallback, take_fft
freq_seg = np.asarray(freq_arr[:take_fft], dtype=np.float64)
valid = np.isfinite(sweep_seg) & np.isfinite(freq_seg)
if int(np.count_nonzero(valid)) < 2:
return fallback, take_fft
x_valid = freq_seg[valid]
y_valid = sweep_seg[valid]
order = np.argsort(x_valid, kind="mergesort")
x_valid = x_valid[order]
y_valid = y_valid[order]
x_unique, unique_idx = np.unique(x_valid, return_index=True)
y_unique = y_valid[unique_idx]
if x_unique.size < 2 or x_unique[-1] <= x_unique[0]:
return fallback, take_fft
x_uniform = np.linspace(float(x_unique[0]), float(x_unique[-1]), take_fft, dtype=np.float64)
resampled = _interp_signal(x_uniform, x_unique, y_unique)
return resampled, take_fft
def build_symmetric_ifft_spectrum(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
fft_len: int = FFT_LEN,
) -> Optional[np.ndarray]:
"""Build a centered symmetric spectrum over [-f_max, f_max] for IFFT."""
if fft_len <= 0:
return None
bounds = _finite_freq_bounds(freqs)
if bounds is None:
f_min = float(SWEEP_FREQ_MIN_GHZ)
f_max = float(SWEEP_FREQ_MAX_GHZ)
else:
f_min, f_max = bounds
freq_axis = np.linspace(-f_max, f_max, int(fft_len), dtype=np.float64)
neg_idx_all = np.flatnonzero(freq_axis <= (-f_min))
pos_idx_all = np.flatnonzero(freq_axis >= f_min)
band_len = int(min(neg_idx_all.size, pos_idx_all.size))
if band_len <= 1:
return None
neg_idx = neg_idx_all[:band_len]
pos_idx = pos_idx_all[-band_len:]
prepared = prepare_fft_segment(sweep, freqs, fft_len=band_len)
if prepared is None:
return None
fft_seg, take_fft = prepared
if take_fft != band_len:
fft_dtype = np.complex64 if np.iscomplexobj(fft_seg) else np.float32
fft_seg = np.asarray(fft_seg[:band_len], dtype=fft_dtype)
if fft_seg.size < band_len:
padded = np.zeros((band_len,), dtype=fft_dtype)
padded[: fft_seg.size] = fft_seg
fft_seg = padded
window = np.hanning(band_len).astype(np.float32)
band_dtype = np.complex64 if np.iscomplexobj(fft_seg) else np.float32
band = np.nan_to_num(fft_seg, nan=0.0).astype(band_dtype, copy=False) * window
spectrum = np.zeros((int(fft_len),), dtype=band_dtype)
spectrum[pos_idx] = band
spectrum[neg_idx] = np.conj(band[::-1]) if np.iscomplexobj(band) else band[::-1]
return spectrum
def build_positive_only_centered_ifft_spectrum(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
fft_len: int = FFT_LEN,
) -> Optional[np.ndarray]:
"""Build a centered spectrum with zeros from -f_max to +f_min."""
if fft_len <= 0:
return None
bounds = _finite_freq_bounds(freqs)
if bounds is None:
f_min = float(SWEEP_FREQ_MIN_GHZ)
f_max = float(SWEEP_FREQ_MAX_GHZ)
else:
f_min, f_max = bounds
freq_axis = np.linspace(-f_max, f_max, int(fft_len), dtype=np.float64)
pos_idx = np.flatnonzero(freq_axis >= f_min)
band_len = int(pos_idx.size)
if band_len <= 1:
return None
prepared = prepare_fft_segment(sweep, freqs, fft_len=band_len)
if prepared is None:
return None
fft_seg, take_fft = prepared
if take_fft != band_len:
fft_dtype = np.complex64 if np.iscomplexobj(fft_seg) else np.float32
fft_seg = np.asarray(fft_seg[:band_len], dtype=fft_dtype)
if fft_seg.size < band_len:
padded = np.zeros((band_len,), dtype=fft_dtype)
padded[: fft_seg.size] = fft_seg
fft_seg = padded
window = np.hanning(band_len).astype(np.float32)
band_dtype = np.complex64 if np.iscomplexobj(fft_seg) else np.float32
band = np.nan_to_num(fft_seg, nan=0.0).astype(band_dtype, copy=False) * window
spectrum = np.zeros((int(fft_len),), dtype=band_dtype)
spectrum[pos_idx] = band
return spectrum
def build_positive_only_exact_centered_ifft_spectrum(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
*,
max_shift_len: Optional[int] = None,
) -> Optional[np.ndarray]:
"""Build centered spectrum exactly as zeros[-f_max..+f_min) + measured positive band."""
prepared = _extract_positive_exact_band(sweep, freqs)
if prepared is None:
return None
freq_band, sweep_band, _f_max, _df_ghz = prepared
normalized = _normalize_positive_exact_band(
freq_band,
sweep_band,
max_shift_len=max_shift_len,
)
if normalized is None:
return None
freq_band, sweep_band, f_max, df_ghz = normalized
f_shift = np.arange(-f_max, f_max + (0.5 * df_ghz), df_ghz, dtype=np.float64)
if f_shift.size <= 1:
return None
band_dtype = np.complex64 if np.iscomplexobj(sweep_band) else np.float32
band = np.nan_to_num(np.asarray(sweep_band, dtype=band_dtype), nan=0.0)
spectrum = np.zeros((int(f_shift.size),), dtype=band_dtype)
idx = np.round((freq_band - f_shift[0]) / df_ghz).astype(np.int64)
idx = np.clip(idx, 0, spectrum.size - 1)
spectrum[idx] = band
return spectrum
def fft_mag_to_db(mag: np.ndarray) -> np.ndarray:
"""Convert magnitude to dB with safe zero handling."""
mag_arr = np.asarray(mag, dtype=np.float32)
safe_mag = np.maximum(mag_arr, 0.0)
return (20.0 * np.log10(safe_mag + 1e-9)).astype(np.float32, copy=False)
def _compute_fft_complex_row_direct(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
bins: int,
) -> np.ndarray:
prepared = prepare_fft_segment(sweep, freqs, fft_len=FFT_LEN)
if prepared is None:
return np.full((bins,), np.nan + 0j, dtype=np.complex64)
fft_seg, take_fft = prepared
fft_in = np.zeros((FFT_LEN,), dtype=np.complex64)
window = np.hanning(take_fft).astype(np.float32)
fft_in[:take_fft] = np.asarray(fft_seg, dtype=np.complex64) * window
spec = np.fft.ifft(fft_in)
return _fit_complex_bins(spec, bins)
def _normalize_fft_mode(mode: str | None, symmetric: Optional[bool]) -> str:
if symmetric is not None:
return "symmetric" if symmetric else "direct"
normalized = str(mode or "symmetric").strip().lower()
if normalized in {"direct", "ordinary", "normal"}:
return "direct"
if normalized in {"symmetric", "sym", "mirror"}:
return "symmetric"
if normalized in {"positive_only", "positive-centered", "positive_centered", "zero_left"}:
return "positive_only"
if normalized in {"positive_only_exact", "positive-centered-exact", "positive_centered_exact", "zero_left_exact"}:
return "positive_only_exact"
raise ValueError(f"Unsupported FFT mode: {mode!r}")
def compute_fft_complex_row(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
bins: int,
*,
mode: str = "symmetric",
symmetric: Optional[bool] = None,
) -> np.ndarray:
"""Compute a complex FFT/IFFT row on the distance axis."""
if bins <= 0:
return np.zeros((0,), dtype=np.complex64)
fft_mode = _normalize_fft_mode(mode, symmetric)
if fft_mode == "direct":
return _compute_fft_complex_row_direct(sweep, freqs, bins)
if fft_mode == "positive_only":
spectrum_centered = build_positive_only_centered_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
elif fft_mode == "positive_only_exact":
spectrum_centered = build_positive_only_exact_centered_ifft_spectrum(
sweep,
freqs,
max_shift_len=bins,
)
else:
spectrum_centered = build_symmetric_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
if spectrum_centered is None:
return np.full((bins,), np.nan + 0j, dtype=np.complex64)
spec = np.fft.ifft(np.fft.ifftshift(np.asarray(spectrum_centered, dtype=np.complex64)))
return _fit_complex_bins(spec, bins)
def compute_fft_mag_row(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
bins: int,
*,
mode: str = "symmetric",
symmetric: Optional[bool] = None,
) -> np.ndarray:
"""Compute a linear FFT magnitude row."""
complex_row = compute_fft_complex_row(sweep, freqs, bins, mode=mode, symmetric=symmetric)
return np.abs(complex_row).astype(np.float32, copy=False)
def compute_fft_row(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
bins: int,
*,
mode: str = "symmetric",
symmetric: Optional[bool] = None,
) -> np.ndarray:
"""Compute a dB FFT row."""
return fft_mag_to_db(compute_fft_mag_row(sweep, freqs, bins, mode=mode, symmetric=symmetric))
def compute_distance_axis(
freqs: Optional[np.ndarray],
bins: int,
*,
mode: str = "symmetric",
symmetric: Optional[bool] = None,
) -> np.ndarray:
"""Compute the one-way distance axis for IFFT output."""
if bins <= 0:
return np.zeros((0,), dtype=np.float64)
fft_mode = _normalize_fft_mode(mode, symmetric)
if fft_mode == "positive_only_exact":
geometry = _resolve_positive_only_exact_geometry(freqs, max_shift_len=bins)
if geometry is None:
return np.arange(bins, dtype=np.float64)
n_shift, df_hz = geometry
if (not np.isfinite(df_hz)) or df_hz <= 0.0 or n_shift <= 0:
return np.arange(bins, dtype=np.float64)
step_m = C_M_S / (2.0 * float(n_shift) * df_hz)
return np.arange(bins, dtype=np.float64) * step_m
if fft_mode in {"symmetric", "positive_only"}:
bounds = _finite_freq_bounds(freqs)
if bounds is None:
f_max = float(SWEEP_FREQ_MAX_GHZ)
else:
_, f_max = bounds
df_ghz = (2.0 * f_max) / max(1, FFT_LEN - 1)
else:
if freqs is None:
return np.arange(bins, dtype=np.float64)
freq_arr = np.asarray(freqs, dtype=np.float64)
finite = freq_arr[np.isfinite(freq_arr)]
if finite.size < 2:
return np.arange(bins, dtype=np.float64)
df_ghz = float((finite[-1] - finite[0]) / max(1, finite.size - 1))
df_hz = abs(df_ghz) * 1e9
if not np.isfinite(df_hz) or df_hz <= 0.0:
return np.arange(bins, dtype=np.float64)
step_m = C_M_S / (2.0 * FFT_LEN * df_hz)
return np.arange(bins, dtype=np.float64) * step_m

View File

@ -1,71 +0,0 @@
"""Formatting and display-range helpers."""
from __future__ import annotations
from typing import Any, Mapping, Optional, Tuple
import numpy as np
def format_status_kv(data: Mapping[str, Any]) -> str:
"""Convert status metrics into a compact single-line representation."""
def _fmt(value: Any) -> str:
if value is None:
return "NA"
try:
f_value = float(value)
except Exception:
return str(value)
if not np.isfinite(f_value):
return "nan"
if abs(f_value) >= 1000 or (0 < abs(f_value) < 0.01):
return f"{f_value:.3g}"
return f"{f_value:.3f}".rstrip("0").rstrip(".")
return " ".join(f"{key}:{_fmt(value)}" for key, value in data.items())
def parse_spec_clip(spec: Optional[str]) -> Optional[Tuple[float, float]]:
"""Parse a waterfall percentile clip specification."""
if not spec:
return None
value = str(spec).strip().lower()
if value in ("off", "none", "no"):
return None
try:
p0, p1 = value.replace(";", ",").split(",")
low = float(p0)
high = float(p1)
if not (0.0 <= low < high <= 100.0):
return None
return (low, high)
except Exception:
return None
def compute_auto_ylim(*series_list: Optional[np.ndarray]) -> Optional[Tuple[float, float]]:
"""Compute a common Y-range with a small padding."""
y_min: Optional[float] = None
y_max: Optional[float] = None
for series in series_list:
if series is None:
continue
arr = np.asarray(series)
if arr.size == 0:
continue
finite = arr[np.isfinite(arr)]
if finite.size == 0:
continue
cur_min = float(np.min(finite))
cur_max = float(np.max(finite))
y_min = cur_min if y_min is None else min(y_min, cur_min)
y_max = cur_max if y_max is None else max(y_max, cur_max)
if y_min is None or y_max is None:
return None
if y_min == y_max:
pad = max(1.0, abs(y_min) * 0.05)
else:
pad = 0.05 * (y_max - y_min)
return (y_min - pad, y_max + pad)

View File

@ -1,230 +0,0 @@
"""Sweep normalization helpers."""
from __future__ import annotations
from typing import Tuple
import numpy as np
def normalize_sweep_simple(raw: np.ndarray, calib: np.ndarray) -> np.ndarray:
"""Simple element-wise raw/calib normalization."""
width = min(raw.size, calib.size)
if width <= 0:
return raw
out = np.full_like(raw, np.nan, dtype=np.float32)
with np.errstate(divide="ignore", invalid="ignore"):
out[:width] = raw[:width] / calib[:width]
out = np.nan_to_num(out, nan=np.nan, posinf=np.nan, neginf=np.nan)
return out
def build_calib_envelopes(calib: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
"""Estimate smooth lower/upper envelopes from local extrema."""
n = int(calib.size)
if n <= 0:
empty = np.zeros((0,), dtype=np.float32)
return empty, empty
values = np.asarray(calib, dtype=np.float32)
finite = np.isfinite(values)
if not np.any(finite):
zeros = np.zeros_like(values, dtype=np.float32)
return zeros, zeros
if not np.all(finite):
x = np.arange(n, dtype=np.float32)
values = values.copy()
values[~finite] = np.interp(x[~finite], x[finite], values[finite]).astype(np.float32)
if n < 3:
return values.copy(), values.copy()
x = np.arange(n, dtype=np.float32)
def _moving_average(series: np.ndarray, window: int) -> np.ndarray:
width = max(1, int(window))
if width <= 1 or series.size <= 2:
return np.asarray(series, dtype=np.float32).copy()
if width % 2 == 0:
width += 1
pad = width // 2
padded = np.pad(np.asarray(series, dtype=np.float32), (pad, pad), mode="edge")
kernel = np.full((width,), 1.0 / float(width), dtype=np.float32)
return np.convolve(padded, kernel, mode="valid").astype(np.float32)
def _smooth_extrema_envelope(use_max: bool) -> np.ndarray:
step = max(3, n // 32)
node_idx_list = []
for start in range(0, n, step):
stop = min(n, start + step)
segment = values[start:stop]
idx_rel = int(np.argmax(segment) if use_max else np.argmin(segment))
node_idx_list.append(start + idx_rel)
extrema_idx = np.unique(np.asarray(node_idx_list, dtype=np.int64))
if extrema_idx.size == 0:
extrema_idx = np.asarray([int(np.argmax(values) if use_max else np.argmin(values))], dtype=np.int64)
node_idx = np.unique(np.concatenate(([0], extrema_idx, [n - 1]))).astype(np.int64)
node_vals = values[node_idx].astype(np.float32, copy=True)
node_vals[0] = float(values[extrema_idx[0]])
node_vals[-1] = float(values[extrema_idx[-1]])
node_vals = _moving_average(node_vals, 3)
node_vals[0] = float(values[extrema_idx[0]])
node_vals[-1] = float(values[extrema_idx[-1]])
envelope = np.interp(x, node_idx.astype(np.float32), node_vals).astype(np.float32)
smooth_window = max(1, n // 64)
if smooth_window > 1:
envelope = _moving_average(envelope, smooth_window)
return envelope
upper = _smooth_extrema_envelope(use_max=True)
lower = _smooth_extrema_envelope(use_max=False)
swap = lower > upper
if np.any(swap):
tmp = upper[swap].copy()
upper[swap] = lower[swap]
lower[swap] = tmp
return lower, upper
def normalize_sweep_projector(raw: np.ndarray, calib: np.ndarray) -> np.ndarray:
"""Project raw values between calibration envelopes into [-1000, 1000]."""
width = min(raw.size, calib.size)
if width <= 0:
return raw
out = np.full_like(raw, np.nan, dtype=np.float32)
raw_seg = np.asarray(raw[:width], dtype=np.float32)
lower, upper = build_calib_envelopes(np.asarray(calib[:width], dtype=np.float32))
span = upper - lower
finite_span = span[np.isfinite(span) & (span > 0)]
if finite_span.size > 0:
eps = max(float(np.median(finite_span)) * 1e-6, 1e-9)
else:
eps = 1e-9
valid = (
np.isfinite(raw_seg)
& np.isfinite(lower)
& np.isfinite(upper)
& (span > eps)
)
if np.any(valid):
proj = np.empty_like(raw_seg, dtype=np.float32)
proj[valid] = ((2.0 * (raw_seg[valid] - lower[valid]) / span[valid]) - 1.0) * 1000.0
proj[valid] = np.clip(proj[valid], -1000.0, 1000.0)
proj[~valid] = np.nan
out[:width] = proj
return out
def resample_envelope(envelope: np.ndarray, width: int) -> np.ndarray:
"""Resample an envelope to the target sweep width on the index axis."""
target_width = int(width)
if target_width <= 0:
return np.zeros((0,), dtype=np.float32)
values = np.asarray(envelope, dtype=np.float32).reshape(-1)
if values.size == 0:
return np.full((target_width,), np.nan, dtype=np.float32)
if values.size == target_width:
return values.astype(np.float32, copy=True)
x_src = np.arange(values.size, dtype=np.float32)
finite = np.isfinite(values)
if not np.any(finite):
return np.full((target_width,), np.nan, dtype=np.float32)
if int(np.count_nonzero(finite)) == 1:
fill = float(values[finite][0])
return np.full((target_width,), fill, dtype=np.float32)
x_dst = np.linspace(0.0, float(values.size - 1), target_width, dtype=np.float32)
return np.interp(x_dst, x_src[finite], values[finite]).astype(np.float32)
def fit_complex_calibration_to_width(calib: np.ndarray, width: int) -> np.ndarray:
"""Fit a complex calibration curve to the signal width via trim/pad with ones."""
target_width = int(width)
if target_width <= 0:
return np.zeros((0,), dtype=np.complex64)
values = np.asarray(calib, dtype=np.complex64).reshape(-1)
if values.size <= 0:
return np.ones((target_width,), dtype=np.complex64)
if values.size == target_width:
return values.astype(np.complex64, copy=True)
if values.size > target_width:
return np.asarray(values[:target_width], dtype=np.complex64)
out = np.ones((target_width,), dtype=np.complex64)
out[: values.size] = values
return out
def normalize_by_complex_calibration(
signal: np.ndarray,
calib: np.ndarray,
eps: float = 1e-9,
) -> np.ndarray:
"""Normalize complex signal by a complex calibration curve with zero protection."""
sig_arr = np.asarray(signal, dtype=np.complex64).reshape(-1)
if sig_arr.size <= 0:
return sig_arr.copy()
calib_fit = fit_complex_calibration_to_width(calib, sig_arr.size)
eps_abs = max(abs(float(eps)), 1e-12)
denom = np.asarray(calib_fit, dtype=np.complex64).copy()
safe_denom = (
np.isfinite(denom.real)
& np.isfinite(denom.imag)
& (np.abs(denom) >= eps_abs)
)
if np.any(~safe_denom):
denom[~safe_denom] = np.complex64(1.0 + 0.0j)
out = np.full(sig_arr.shape, np.nan + 0j, dtype=np.complex64)
valid_sig = np.isfinite(sig_arr.real) & np.isfinite(sig_arr.imag)
if np.any(valid_sig):
with np.errstate(divide="ignore", invalid="ignore"):
out[valid_sig] = sig_arr[valid_sig] / denom[valid_sig]
out_real = np.nan_to_num(out.real, nan=np.nan, posinf=np.nan, neginf=np.nan)
out_imag = np.nan_to_num(out.imag, nan=np.nan, posinf=np.nan, neginf=np.nan)
return (out_real + (1j * out_imag)).astype(np.complex64, copy=False)
def normalize_by_envelope(raw: np.ndarray, envelope: np.ndarray) -> np.ndarray:
"""Normalize a sweep by an envelope with safe resampling and zero protection."""
raw_in = np.asarray(raw).reshape(-1)
raw_dtype = np.complex64 if np.iscomplexobj(raw_in) else np.float32
raw_arr = np.asarray(raw_in, dtype=raw_dtype).reshape(-1)
if raw_arr.size == 0:
return raw_arr.copy()
env = resample_envelope(envelope, raw_arr.size)
out = np.full(raw_arr.shape, np.nan + 0j if np.iscomplexobj(raw_arr) else np.nan, dtype=raw_dtype)
den_eps = np.float32(1e-9)
valid = np.isfinite(raw_arr) & np.isfinite(env)
if np.any(valid):
with np.errstate(divide="ignore", invalid="ignore"):
denom = env[valid] + np.where(env[valid] >= 0.0, den_eps, -den_eps)
out[valid] = raw_arr[valid] / denom
if np.iscomplexobj(out):
out_real = np.nan_to_num(out.real, nan=np.nan, posinf=np.nan, neginf=np.nan)
out_imag = np.nan_to_num(out.imag, nan=np.nan, posinf=np.nan, neginf=np.nan)
return (out_real + (1j * out_imag)).astype(np.complex64, copy=False)
return np.nan_to_num(out, nan=np.nan, posinf=np.nan, neginf=np.nan)
def normalize_by_calib(raw: np.ndarray, calib: np.ndarray, norm_type: str) -> np.ndarray:
"""Apply the selected normalization method."""
norm = str(norm_type).strip().lower()
if norm == "simple":
return normalize_sweep_simple(raw, calib)
return normalize_sweep_projector(raw, calib)

View File

@ -0,0 +1,149 @@
"""Алгоритмы нормировки свипов по калибровочной кривой."""
from typing import Tuple
import numpy as np
def normalize_simple(raw: np.ndarray, calib: np.ndarray) -> np.ndarray:
"""Простая нормировка: поэлементное деление raw/calib."""
w = min(raw.size, calib.size)
if w <= 0:
return raw
out = np.full_like(raw, np.nan, dtype=np.float32)
with np.errstate(divide="ignore", invalid="ignore"):
out[:w] = raw[:w] / calib[:w]
out = np.nan_to_num(out, nan=np.nan, posinf=np.nan, neginf=np.nan)
return out
def build_calib_envelopes(calib: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
"""Оценить огибающую по модулю сигнала.
Возвращает (lower, upper) = (-envelope, +envelope), где envelope —
интерполяция через локальные максимумы |calib|.
"""
n = int(calib.size)
if n <= 0:
empty = np.zeros((0,), dtype=np.float32)
return empty, empty
y = np.asarray(calib, dtype=np.float32)
finite = np.isfinite(y)
if not np.any(finite):
zeros = np.zeros_like(y, dtype=np.float32)
return zeros, zeros
if not np.all(finite):
x = np.arange(n, dtype=np.float32)
y = y.copy()
y[~finite] = np.interp(x[~finite], x[finite], y[finite]).astype(np.float32)
a = np.abs(y)
if n < 3:
env = a.copy()
return -env, env
da = np.diff(a)
s = np.sign(da).astype(np.int8, copy=False)
if np.any(s == 0):
for i in range(1, s.size):
if s[i] == 0:
s[i] = s[i - 1]
for i in range(s.size - 2, -1, -1):
if s[i] == 0:
s[i] = s[i + 1]
s[s == 0] = 1
max_idx = np.where((s[:-1] > 0) & (s[1:] < 0))[0] + 1
x = np.arange(n, dtype=np.float32)
if max_idx.size == 0:
idx = np.array([0, n - 1], dtype=np.int64)
else:
idx = np.unique(np.concatenate(([0], max_idx, [n - 1]))).astype(np.int64)
env = np.interp(x, idx.astype(np.float32), a[idx]).astype(np.float32)
return -env, env
def normalize_projector(raw: np.ndarray, calib: np.ndarray) -> np.ndarray:
"""Нормировка через проекцию между огибающими калибровки в диапазон [-1000, +1000]."""
w = min(raw.size, calib.size)
if w <= 0:
return raw
out = np.full_like(raw, np.nan, dtype=np.float32)
raw_seg = np.asarray(raw[:w], dtype=np.float32)
lower, upper = build_calib_envelopes(np.asarray(calib[:w], dtype=np.float32))
span = upper - lower
finite_span = span[np.isfinite(span) & (span > 0)]
if finite_span.size > 0:
eps = max(float(np.median(finite_span)) * 1e-6, 1e-9)
else:
eps = 1e-9
valid = (
np.isfinite(raw_seg)
& np.isfinite(lower)
& np.isfinite(upper)
& (span > eps)
)
if np.any(valid):
proj = np.empty_like(raw_seg, dtype=np.float32)
proj[valid] = ((2.0 * (raw_seg[valid] - lower[valid]) / span[valid]) - 1.0) * 1000.0
proj[valid] = np.clip(proj[valid], -1000.0, 1000.0)
proj[~valid] = np.nan
out[:w] = proj
return out
def normalize_by_calib(raw: np.ndarray, calib: np.ndarray, norm_type: str) -> np.ndarray:
"""Нормировка свипа по выбранному алгоритму."""
nt = str(norm_type).strip().lower()
if nt == "simple":
return normalize_simple(raw, calib)
return normalize_projector(raw, calib)
def normalize_by_envelope(raw: np.ndarray, envelope: np.ndarray) -> np.ndarray:
"""Нормировка свипа через проекцию на огибающую из файла.
Воспроизводит логику normalize_projector: проецирует raw в [-1000, +1000]
используя готовую верхнюю огибающую (upper = envelope, lower = -envelope).
"""
w = min(raw.size, envelope.size)
if w <= 0:
return raw
out = np.full_like(raw, np.nan, dtype=np.float32)
raw_seg = np.asarray(raw[:w], dtype=np.float32)
upper = np.asarray(envelope[:w], dtype=np.float32)
lower = -upper
span = upper - lower # = 2 * upper
finite_span = span[np.isfinite(span) & (span > 0)]
if finite_span.size > 0:
eps = max(float(np.median(finite_span)) * 1e-6, 1e-9)
else:
eps = 1e-9
valid = (
np.isfinite(raw_seg)
& np.isfinite(lower)
& np.isfinite(upper)
& (span > eps)
)
if np.any(valid):
proj = np.empty_like(raw_seg, dtype=np.float32)
proj[valid] = ((2.0 * (raw_seg[valid] - lower[valid]) / span[valid]) - 1.0) * 1000.0
proj[valid] = np.clip(proj[valid], -1000.0, 1000.0)
proj[~valid] = np.nan
out[:w] = proj
return out

View File

@ -1,209 +0,0 @@
"""Peak-search helpers for FFT visualizations."""
from __future__ import annotations
from typing import Dict, List, Optional
import numpy as np
def find_peak_width_markers(xs: np.ndarray, ys: np.ndarray) -> Optional[Dict[str, float]]:
"""Find the dominant non-zero peak and its half-height width."""
x_arr = np.asarray(xs, dtype=np.float64)
y_arr = np.asarray(ys, dtype=np.float64)
valid = np.isfinite(x_arr) & np.isfinite(y_arr) & (x_arr > 0.0)
if int(np.count_nonzero(valid)) < 3:
return None
x = x_arr[valid]
y = y_arr[valid]
x_min = float(x[0])
x_max = float(x[-1])
x_span = x_max - x_min
central_mask = (x >= (x_min + 0.25 * x_span)) & (x <= (x_min + 0.75 * x_span))
if int(np.count_nonzero(central_mask)) > 0:
central_idx = np.flatnonzero(central_mask)
peak_idx = int(central_idx[int(np.argmax(y[central_mask]))])
else:
peak_idx = int(np.argmax(y))
peak_y = float(y[peak_idx])
shoulder_gap = max(1, min(8, y.size // 64 if y.size > 0 else 1))
shoulder_width = max(4, min(32, y.size // 16 if y.size > 0 else 4))
left_lo = max(0, peak_idx - shoulder_gap - shoulder_width)
left_hi = max(0, peak_idx - shoulder_gap)
right_lo = min(y.size, peak_idx + shoulder_gap + 1)
right_hi = min(y.size, right_lo + shoulder_width)
background_parts = []
if left_hi > left_lo:
background_parts.append(float(np.nanmedian(y[left_lo:left_hi])))
if right_hi > right_lo:
background_parts.append(float(np.nanmedian(y[right_lo:right_hi])))
if background_parts:
background = float(np.mean(background_parts))
else:
background = float(np.nanpercentile(y, 10))
if not np.isfinite(peak_y) or not np.isfinite(background) or peak_y <= background:
return None
half_level = background + 0.5 * (peak_y - background)
def _interp_cross(x0: float, y0: float, x1: float, y1: float) -> float:
if not (np.isfinite(x0) and np.isfinite(y0) and np.isfinite(x1) and np.isfinite(y1)):
return x1
dy = y1 - y0
if dy == 0.0:
return x1
t = (half_level - y0) / dy
t = min(1.0, max(0.0, t))
return x0 + t * (x1 - x0)
left_x = float(x[0])
for i in range(peak_idx, 0, -1):
if y[i - 1] <= half_level <= y[i]:
left_x = _interp_cross(float(x[i - 1]), float(y[i - 1]), float(x[i]), float(y[i]))
break
right_x = float(x[-1])
for i in range(peak_idx, x.size - 1):
if y[i] >= half_level >= y[i + 1]:
right_x = _interp_cross(float(x[i]), float(y[i]), float(x[i + 1]), float(y[i + 1]))
break
width = right_x - left_x
if not np.isfinite(width) or width <= 0.0:
return None
return {
"background": background,
"left": left_x,
"right": right_x,
"width": width,
"amplitude": peak_y,
}
def rolling_median_ref(xs: np.ndarray, ys: np.ndarray, window_ghz: float) -> np.ndarray:
"""Compute a rolling median reference on a fixed-width X window."""
x = np.asarray(xs, dtype=np.float64)
y = np.asarray(ys, dtype=np.float64)
out = np.full(y.shape, np.nan, dtype=np.float64)
if x.size == 0 or y.size == 0 or x.size != y.size:
return out
width = float(window_ghz)
if not np.isfinite(width) or width <= 0.0:
return out
half = 0.5 * width
for i in range(x.size):
xi = x[i]
if not np.isfinite(xi):
continue
left = np.searchsorted(x, xi - half, side="left")
right = np.searchsorted(x, xi + half, side="right")
if right <= left:
continue
segment = y[left:right]
finite = np.isfinite(segment)
if not np.any(finite):
continue
out[i] = float(np.nanmedian(segment))
return out
def find_top_peaks_over_ref(
xs: np.ndarray,
ys: np.ndarray,
ref: np.ndarray,
top_n: int = 3,
) -> List[Dict[str, float]]:
"""Find the top-N non-overlapping peaks above a reference curve."""
x = np.asarray(xs, dtype=np.float64)
y = np.asarray(ys, dtype=np.float64)
r = np.asarray(ref, dtype=np.float64)
if x.size < 3 or y.size != x.size or r.size != x.size:
return []
valid = np.isfinite(x) & np.isfinite(y) & np.isfinite(r)
if not np.any(valid):
return []
delta = np.full_like(y, np.nan, dtype=np.float64)
delta[valid] = y[valid] - r[valid]
candidates: List[int] = []
for i in range(1, x.size - 1):
if not (np.isfinite(delta[i - 1]) and np.isfinite(delta[i]) and np.isfinite(delta[i + 1])):
continue
if delta[i] <= 0.0:
continue
left_ok = delta[i] > delta[i - 1]
right_ok = delta[i] >= delta[i + 1]
alt_left_ok = delta[i] >= delta[i - 1]
alt_right_ok = delta[i] > delta[i + 1]
if (left_ok and right_ok) or (alt_left_ok and alt_right_ok):
candidates.append(i)
if not candidates:
return []
candidates.sort(key=lambda i: float(delta[i]), reverse=True)
def _interp_cross(x0: float, y0: float, x1: float, y1: float, y_cross: float) -> float:
dy = y1 - y0
if not np.isfinite(dy) or dy == 0.0:
return x1
t = (y_cross - y0) / dy
t = min(1.0, max(0.0, t))
return x0 + t * (x1 - x0)
picked: List[Dict[str, float]] = []
for idx in candidates:
peak_y = float(y[idx])
peak_ref = float(r[idx])
peak_h = float(delta[idx])
if not (np.isfinite(peak_y) and np.isfinite(peak_ref) and np.isfinite(peak_h)) or peak_h <= 0.0:
continue
half_level = peak_ref + 0.5 * peak_h
left_x = float(x[0])
for i in range(idx, 0, -1):
y0 = float(y[i - 1])
y1 = float(y[i])
if np.isfinite(y0) and np.isfinite(y1) and (y0 <= half_level <= y1):
left_x = _interp_cross(float(x[i - 1]), y0, float(x[i]), y1, half_level)
break
right_x = float(x[-1])
for i in range(idx, x.size - 1):
y0 = float(y[i])
y1 = float(y[i + 1])
if np.isfinite(y0) and np.isfinite(y1) and (y0 >= half_level >= y1):
right_x = _interp_cross(float(x[i]), y0, float(x[i + 1]), y1, half_level)
break
width = float(right_x - left_x)
if not np.isfinite(width) or width <= 0.0:
continue
overlap = False
for peak in picked:
if not (right_x <= peak["left"] or left_x >= peak["right"]):
overlap = True
break
if overlap:
continue
picked.append(
{
"x": float(x[idx]),
"peak_y": peak_y,
"ref": peak_ref,
"height": peak_h,
"left": left_x,
"right": right_x,
"width": width,
}
)
if len(picked) >= int(max(1, top_n)):
break
picked.sort(key=lambda peak: peak["x"])
return picked

View File

@ -1,7 +0,0 @@
"""Runtime state helpers."""
from rfg_adc_plotter.state.background_buffer import BackgroundMedianBuffer
from rfg_adc_plotter.state.ring_buffer import RingBuffer
from rfg_adc_plotter.state.runtime_state import RuntimeState
__all__ = ["BackgroundMedianBuffer", "RingBuffer", "RuntimeState"]

View File

@ -0,0 +1,227 @@
"""Состояние приложения: текущие свипы и настройки калибровки/нормировки."""
import os
from queue import Empty, Queue
from typing import Any, Dict, Mapping, Optional
import numpy as np
from rfg_adc_plotter.processing.normalizer import (
build_calib_envelopes,
normalize_by_calib,
normalize_by_envelope,
)
from rfg_adc_plotter.state.ring_buffer import RingBuffer
from rfg_adc_plotter.types import SweepInfo, SweepPacket
CALIB_ENVELOPE_PATH = "calib_envelope.npy"
BACKGROUND_PATH = "background.npy"
def format_status(data: Mapping[str, Any]) -> str:
"""Преобразовать словарь метрик в одну строку 'k:v'."""
def _fmt(v: Any) -> str:
if v is None:
return "NA"
try:
fv = float(v)
except Exception:
return str(v)
if not np.isfinite(fv):
return "nan"
if abs(fv) >= 1000 or (0 < abs(fv) < 0.01):
return f"{fv:.3g}"
return f"{fv:.3f}".rstrip("0").rstrip(".")
parts = [f"{k}:{_fmt(v)}" for k, v in data.items()]
return " ".join(parts)
class AppState:
"""Весь изменяемый GUI-state: текущие данные, калибровка, настройки.
Методы drain_queue и set_calib_enabled заменяют одноимённые closures
с nonlocal из оригинального кода.
"""
def __init__(self, norm_type: str = "projector"):
self.current_sweep_raw: Optional[np.ndarray] = None
self.current_sweep_norm: Optional[np.ndarray] = None
self.last_calib_sweep: Optional[np.ndarray] = None
self.current_info: Optional[SweepInfo] = None
self.calib_enabled: bool = False
self.norm_type: str = norm_type
# "live" — нормировка по текущему ch0-свипу; "file" — по огибающей из файла
self.calib_mode: str = "live"
self.calib_file_envelope: Optional[np.ndarray] = None
# Вычет фона
self.background: Optional[np.ndarray] = None
self.background_enabled: bool = False
self._last_sweep_for_ring: Optional[np.ndarray] = None
def _normalize(self, raw: np.ndarray, calib: np.ndarray) -> np.ndarray:
if self.calib_mode == "file" and self.calib_file_envelope is not None:
return normalize_by_envelope(raw, self.calib_file_envelope)
return normalize_by_calib(raw, calib, self.norm_type)
def save_calib_envelope(self, path: str = CALIB_ENVELOPE_PATH) -> bool:
"""Вычислить огибающую из last_calib_sweep и сохранить в файл.
Возвращает True при успехе.
"""
if self.last_calib_sweep is None:
return False
try:
_lower, upper = build_calib_envelopes(self.last_calib_sweep)
np.save(path, upper)
return True
except Exception as exc:
import sys
sys.stderr.write(f"[warn] Не удалось сохранить огибающую: {exc}\n")
return False
def load_calib_envelope(self, path: str = CALIB_ENVELOPE_PATH) -> bool:
"""Загрузить огибающую из файла.
Возвращает True при успехе.
"""
if not os.path.isfile(path):
return False
try:
env = np.load(path)
self.calib_file_envelope = np.asarray(env, dtype=np.float32)
return True
except Exception as exc:
import sys
sys.stderr.write(f"[warn] Не удалось загрузить огибающую: {exc}\n")
return False
def set_calib_mode(self, mode: str):
"""Переключить режим калибровки: 'live' или 'file'."""
self.calib_mode = mode
def save_background(self, path: str = BACKGROUND_PATH) -> bool:
"""Сохранить текущий sweep_for_ring как фоновый спектр.
Сохраняет последний свип, который был записан в ринг-буфер
(нормированный, если калибровка включена, иначе сырой).
Возвращает True при успехе.
"""
if self._last_sweep_for_ring is None:
return False
try:
np.save(path, self._last_sweep_for_ring)
return True
except Exception as exc:
import sys
sys.stderr.write(f"[warn] Не удалось сохранить фон: {exc}\n")
return False
def load_background(self, path: str = BACKGROUND_PATH) -> bool:
"""Загрузить фоновый спектр из файла.
Возвращает True при успехе.
"""
if not os.path.isfile(path):
return False
try:
bg = np.load(path)
self.background = np.asarray(bg, dtype=np.float32)
return True
except Exception as exc:
import sys
sys.stderr.write(f"[warn] Не удалось загрузить фон: {exc}\n")
return False
def set_background_enabled(self, enabled: bool):
"""Включить/выключить вычет фона."""
self.background_enabled = enabled
def set_calib_enabled(self, enabled: bool):
"""Включить/выключить режим калибровки, пересчитать norm-свип."""
self.calib_enabled = enabled
if self.calib_enabled and self.current_sweep_raw is not None:
if self.calib_mode == "file" and self.calib_file_envelope is not None:
self.current_sweep_norm = normalize_by_envelope(
self.current_sweep_raw, self.calib_file_envelope
)
elif self.calib_mode == "live" and self.last_calib_sweep is not None:
self.current_sweep_norm = self._normalize(
self.current_sweep_raw, self.last_calib_sweep
)
else:
self.current_sweep_norm = None
else:
self.current_sweep_norm = None
def drain_queue(self, q: "Queue[SweepPacket]", ring: RingBuffer) -> int:
"""Вытащить все ожидающие свипы из очереди, обновить state и ring.
Возвращает количество обработанных свипов.
"""
drained = 0
while True:
try:
s, info = q.get_nowait()
except Empty:
break
drained += 1
self.current_sweep_raw = s
self.current_info = info
ch = 0
try:
ch = int(info.get("ch", 0)) if isinstance(info, dict) else 0
except Exception:
ch = 0
# Канал 0 — опорный (калибровочный) свип
if ch == 0:
self.last_calib_sweep = s
self.save_calib_envelope()
self.current_sweep_norm = None
sweep_for_ring = s
self._last_sweep_for_ring = sweep_for_ring
else:
can_normalize = self.calib_enabled and (
(self.calib_mode == "file" and self.calib_file_envelope is not None)
or (self.calib_mode == "live" and self.last_calib_sweep is not None)
)
if can_normalize:
calib_ref = self.last_calib_sweep if self.last_calib_sweep is not None else s
self.current_sweep_norm = self._normalize(s, calib_ref)
sweep_for_ring = self.current_sweep_norm
else:
self.current_sweep_norm = None
sweep_for_ring = s
# Вычет фона (в том же домене что и sweep_for_ring)
if self.background_enabled and self.background is not None and ch != 0:
w = min(sweep_for_ring.size, self.background.size)
sweep_for_ring = sweep_for_ring.copy()
sweep_for_ring[:w] -= self.background[:w]
self.current_sweep_norm = sweep_for_ring
self._last_sweep_for_ring = sweep_for_ring
ring.ensure_init(s.size)
ring.push(sweep_for_ring)
return drained
def format_channel_label(self) -> str:
"""Строка с номерами каналов для подписи на графике."""
if self.current_info is None:
return ""
info = self.current_info
chs = info.get("chs") if isinstance(info, dict) else None
if chs is None:
chs = info.get("ch") if isinstance(info, dict) else None
if chs is None:
return ""
try:
if isinstance(chs, (list, tuple, set)):
ch_list = sorted(int(v) for v in chs)
return "chs " + ", ".join(str(v) for v in ch_list)
return f"chs {int(chs)}"
except Exception:
return f"chs {chs}"

View File

@ -1,49 +0,0 @@
"""Rolling median buffer for persisted FFT background capture."""
from __future__ import annotations
from typing import Optional
import numpy as np
class BackgroundMedianBuffer:
"""Store recent FFT rows and expose their median profile."""
def __init__(self, max_rows: int):
self.max_rows = max(1, int(max_rows))
self.width = 0
self.head = 0
self.count = 0
self.rows: Optional[np.ndarray] = None
def reset(self) -> None:
self.width = 0
self.head = 0
self.count = 0
self.rows = None
def push(self, fft_mag: np.ndarray) -> None:
values = np.asarray(fft_mag, dtype=np.float32).reshape(-1)
if values.size == 0:
return
if self.rows is None or self.width != values.size:
self.width = values.size
self.rows = np.full((self.max_rows, self.width), np.nan, dtype=np.float32)
self.head = 0
self.count = 0
self.rows[self.head, :] = values
self.head = (self.head + 1) % self.max_rows
self.count = min(self.count + 1, self.max_rows)
def median(self) -> Optional[np.ndarray]:
if self.rows is None or self.count <= 0:
return None
rows = self.rows[: self.count] if self.count < self.max_rows else self.rows
valid_rows = np.any(np.isfinite(rows), axis=1)
if not np.any(valid_rows):
return None
median = np.nanmedian(rows[valid_rows], axis=0).astype(np.float32, copy=False)
if not np.any(np.isfinite(median)):
return None
return np.nan_to_num(median, nan=0.0).astype(np.float32, copy=False)

View File

@ -1,266 +1,187 @@
"""Ring buffers for raw sweeps and FFT waterfall rows.""" """Кольцевой буфер свипов и FFT-строк для водопадного отображения."""
from __future__ import annotations
import time import time
from typing import Optional from typing import Optional, Tuple
import numpy as np import numpy as np
from rfg_adc_plotter.constants import FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ, WF_WIDTH from rfg_adc_plotter.constants import (
from rfg_adc_plotter.processing.fft import compute_distance_axis, compute_fft_mag_row, fft_mag_to_db FFT_LEN,
FREQ_SPAN_GHZ,
IFFT_LEN,
SWEEP_LEN,
WF_WIDTH,
ZEROS_LOW,
ZEROS_MID,
)
class RingBuffer: class RingBuffer:
"""Store raw sweeps, FFT rows, and matching time markers.""" """Хранит последние N свипов и соответствующие FFT-строки.
Все мутабельные поля водопада инкапсулированы здесь,
что устраняет необходимость nonlocal в GUI-коде.
"""
def __init__(self, max_sweeps: int): def __init__(self, max_sweeps: int):
self.max_sweeps = int(max_sweeps) self.max_sweeps = max_sweeps
self.fft_bins = FFT_LEN // 2 + 1 self.fft_bins = IFFT_LEN # = 1953 (полная длина IFFT-результата)
self.fft_mode = "symmetric"
self.width = 0 # Инициализируются при первом свипе (ensure_init)
self.head = 0 self.ring: Optional[np.ndarray] = None # (max_sweeps, WF_WIDTH)
self.ring: Optional[np.ndarray] = None self.ring_fft: Optional[np.ndarray] = None # (max_sweeps, fft_bins)
self.ring_time: Optional[np.ndarray] = None self.ring_time: Optional[np.ndarray] = None # (max_sweeps,)
self.ring_fft: Optional[np.ndarray] = None self.head: int = 0
self.ring_fft_input: Optional[np.ndarray] = None self.width: Optional[int] = None
self.x_shared: Optional[np.ndarray] = None self.x_shared: Optional[np.ndarray] = None
self.distance_axis: Optional[np.ndarray] = None self.fft_time_axis: Optional[np.ndarray] = None # временная ось IFFT в нс
self.last_fft_mag: Optional[np.ndarray] = None
self.last_fft_db: Optional[np.ndarray] = None
self.last_freqs: Optional[np.ndarray] = None
self.y_min_fft: Optional[float] = None self.y_min_fft: Optional[float] = None
self.y_max_fft: Optional[float] = None self.y_max_fft: Optional[float] = None
self.last_push_valid_points = 0 # FFT последнего свипа (для отображения без повторного вычисления)
self.last_push_fft_valid = False self.last_fft_vals: Optional[np.ndarray] = None
self.last_push_axis_valid = False
@property @property
def is_ready(self) -> bool: def is_ready(self) -> bool:
return self.ring is not None and self.ring_fft is not None return self.ring is not None
@property def ensure_init(self, sweep_width: int):
def fft_symmetric(self) -> bool: """Инициализировать буферы при первом свипе. Повторные вызовы — no-op (кроме x_shared)."""
return self.fft_mode == "symmetric" if self.ring is None:
self.width = WF_WIDTH
def reset(self) -> None:
"""Drop all buffered sweeps and derived FFT state."""
self.width = 0
self.head = 0
self.ring = None
self.ring_time = None
self.ring_fft = None
self.ring_fft_input = None
self.x_shared = None
self.distance_axis = None
self.last_fft_mag = None
self.last_fft_db = None
self.last_freqs = None
self.y_min_fft = None
self.y_max_fft = None
self.last_push_valid_points = 0
self.last_push_fft_valid = False
self.last_push_axis_valid = False
def _promote_fft_cache(self, fft_mag: np.ndarray) -> bool:
fft_mag_arr = np.asarray(fft_mag, dtype=np.float32).reshape(-1)
if fft_mag_arr.size <= 0:
self.last_push_fft_valid = False
return False
fft_db = fft_mag_to_db(fft_mag_arr)
finite_db = fft_db[np.isfinite(fft_db)]
if finite_db.size <= 0:
self.last_push_fft_valid = False
return False
self.last_fft_mag = fft_mag_arr.copy()
self.last_fft_db = fft_db
fr_min = float(np.min(finite_db))
fr_max = float(np.max(finite_db))
self.y_min_fft = fr_min if self.y_min_fft is None else min(self.y_min_fft, fr_min)
self.y_max_fft = fr_max if self.y_max_fft is None else max(self.y_max_fft, fr_max)
self.last_push_fft_valid = True
return True
def _promote_distance_axis(self, axis: np.ndarray) -> bool:
axis_arr = np.asarray(axis, dtype=np.float64).reshape(-1)
if axis_arr.size <= 0 or not np.all(np.isfinite(axis_arr)):
self.last_push_axis_valid = False
return False
self.distance_axis = axis_arr.copy()
self.last_push_axis_valid = True
return True
def ensure_init(self, sweep_width: int) -> bool:
"""Allocate or resize buffers. Returns True when geometry changed."""
target_width = max(int(sweep_width), int(WF_WIDTH))
changed = False
if self.ring is None or self.ring_time is None or self.ring_fft is None:
self.width = target_width
self.ring = np.full((self.max_sweeps, self.width), np.nan, dtype=np.float32) self.ring = np.full((self.max_sweeps, self.width), np.nan, dtype=np.float32)
self.ring_time = np.full((self.max_sweeps,), np.nan, dtype=np.float64) self.ring_time = np.full((self.max_sweeps,), np.nan, dtype=np.float64)
self.ring_fft = np.full((self.max_sweeps, self.fft_bins), np.nan, dtype=np.float32) self.ring_fft = np.full((self.max_sweeps, self.fft_bins), np.nan, dtype=np.float32)
self.ring_fft_input = np.full((self.max_sweeps, self.width), np.nan + 0j, dtype=np.complex64) # Временная ось IFFT: шаг dt = 1/(FREQ_SPAN_GHZ*1e9), переведём в нс
self.fft_time_axis = (
np.arange(IFFT_LEN, dtype=np.float64) / (FREQ_SPAN_GHZ * 1e9) * 1e9
).astype(np.float32)
self.head = 0 self.head = 0
changed = True # Обновляем x_shared если пришёл свип большего размера
elif target_width != self.width: if self.x_shared is None or sweep_width > self.x_shared.size:
new_ring = np.full((self.max_sweeps, target_width), np.nan, dtype=np.float32) self.x_shared = np.linspace(3.323, 14.323, sweep_width, dtype=np.float32)
new_fft_input = np.full((self.max_sweeps, target_width), np.nan + 0j, dtype=np.complex64)
take = min(self.width, target_width)
new_ring[:, :take] = self.ring[:, :take]
if self.ring_fft_input is not None:
new_fft_input[:, :take] = self.ring_fft_input[:, :take]
self.ring = new_ring
self.ring_fft_input = new_fft_input
self.width = target_width
changed = True
if self.x_shared is None or self.x_shared.size != self.width: def push(self, s: np.ndarray):
self.x_shared = np.linspace( """Добавить строку свипа в кольцевой буфер, вычислить FFT-строку."""
SWEEP_FREQ_MIN_GHZ, if s is None or s.size == 0 or self.ring is None:
SWEEP_FREQ_MAX_GHZ,
self.width,
dtype=np.float32,
)
changed = True
return changed
def set_fft_mode(self, mode: str) -> bool:
"""Switch FFT mode and rebuild cached FFT rows from stored sweeps."""
normalized_mode = str(mode).strip().lower()
if normalized_mode in {"ordinary", "normal"}:
normalized_mode = "direct"
if normalized_mode in {"sym", "mirror"}:
normalized_mode = "symmetric"
if normalized_mode in {"positive-centered", "positive_centered", "zero_left"}:
normalized_mode = "positive_only"
if normalized_mode in {"positive-centered-exact", "positive_centered_exact", "zero_left_exact"}:
normalized_mode = "positive_only_exact"
if normalized_mode not in {"direct", "symmetric", "positive_only", "positive_only_exact"}:
raise ValueError(f"Unsupported FFT mode: {mode!r}")
if normalized_mode == self.fft_mode:
return False
self.fft_mode = normalized_mode
self.y_min_fft = None
self.y_max_fft = None
self.last_push_fft_valid = False
self.last_push_axis_valid = False
if self.ring is None or self.ring_fft is None:
return True
self.ring_fft.fill(np.nan)
for row_idx in range(self.ring.shape[0]):
fft_source_row = self.ring_fft_input[row_idx] if self.ring_fft_input is not None else self.ring[row_idx]
if not np.any(np.isfinite(fft_source_row)):
continue
finite_idx = np.flatnonzero(np.isfinite(fft_source_row))
if finite_idx.size <= 0:
continue
row_width = int(finite_idx[-1]) + 1
fft_source = fft_source_row[:row_width]
freqs = self.last_freqs[:row_width] if self.last_freqs is not None and self.last_freqs.size >= row_width else self.last_freqs
fft_mag = compute_fft_mag_row(
fft_source,
freqs,
self.fft_bins,
mode=self.fft_mode,
)
self.ring_fft[row_idx, :] = fft_mag
if self.last_freqs is not None:
self._promote_distance_axis(
compute_distance_axis(
self.last_freqs,
self.fft_bins,
mode=self.fft_mode,
)
)
last_idx = (self.head - 1) % self.max_sweeps
if self.ring_fft.shape[0] > 0:
last_fft = self.ring_fft[last_idx]
self._promote_fft_cache(last_fft)
finite = self.ring_fft[np.isfinite(self.ring_fft)]
if finite.size > 0:
finite_db = fft_mag_to_db(finite.astype(np.float32, copy=False))
self.y_min_fft = float(np.nanmin(finite_db))
self.y_max_fft = float(np.nanmax(finite_db))
return True
def set_symmetric_fft_enabled(self, enabled: bool) -> bool:
"""Backward-compatible wrapper for the old two-state FFT switch."""
return self.set_fft_mode("symmetric" if enabled else "direct")
def push(
self,
sweep: np.ndarray,
freqs: Optional[np.ndarray] = None,
*,
fft_input: Optional[np.ndarray] = None,
) -> None:
"""Push a processed sweep and refresh raw/FFT buffers."""
if sweep is None or sweep.size == 0:
return return
self.ensure_init(int(sweep.size)) w = self.ring.shape[1]
if self.ring is None or self.ring_time is None or self.ring_fft is None or self.ring_fft_input is None: row = np.full((w,), np.nan, dtype=np.float32)
return take = min(w, s.size)
row[:take] = s[:take]
row = np.full((self.width,), np.nan, dtype=np.float32)
take = min(self.width, int(sweep.size))
row[:take] = np.asarray(sweep[:take], dtype=np.float32)
self.last_push_valid_points = int(np.count_nonzero(np.isfinite(row[:take])))
self.ring[self.head, :] = row self.ring[self.head, :] = row
self.ring_time[self.head] = time.time() self.ring_time[self.head] = time.time()
if freqs is not None: self.head = (self.head + 1) % self.ring.shape[0]
self.last_freqs = np.asarray(freqs, dtype=np.float64).copy()
fft_source = np.asarray(fft_input if fft_input is not None else sweep).reshape(-1) self._push_fft(s)
fft_row = np.full((self.width,), np.nan + 0j, dtype=np.complex64)
fft_take = min(self.width, int(fft_source.size))
fft_row[:fft_take] = np.asarray(fft_source[:fft_take], dtype=np.complex64)
self.ring_fft_input[self.head, :] = fft_row
fft_mag = compute_fft_mag_row(fft_source, freqs, self.fft_bins, mode=self.fft_mode) def _push_fft(self, s: np.ndarray):
self.ring_fft[self.head, :] = fft_mag bins = self.ring_fft.shape[1] # = IFFT_LEN = 1953
self._promote_fft_cache(fft_mag) if s is None or s.size == 0:
self._promote_distance_axis(compute_distance_axis(freqs, self.fft_bins, mode=self.fft_mode)) fft_row = np.full((bins,), np.nan, dtype=np.float32)
self.head = (self.head + 1) % self.max_sweeps else:
# 1. Взять первые SWEEP_LEN отсчётов (остаток — нули если свип короче)
sig = np.zeros(SWEEP_LEN, dtype=np.float32)
take = min(int(s.size), SWEEP_LEN)
seg = np.nan_to_num(s[:take], nan=0.0).astype(np.float32, copy=False)
sig[:take] = seg
def get_display_raw(self) -> np.ndarray: # 2. Собрать двусторонний спектр:
# [ZEROS_LOW нулей | ZEROS_MID нулей | SWEEP_LEN данных]
# = [-14.3..-3.2 ГГц | -3.2..+3.2 ГГц | +3.2..+14.3 ГГц]
data = np.zeros(IFFT_LEN, dtype=np.complex64)
data[ZEROS_LOW + ZEROS_MID:] = sig
# 3. ifftshift + ifft → временной профиль
spec = np.fft.ifftshift(data)
result = np.fft.ifft(spec)
# 4. Амплитуда в дБ
mag = np.abs(result).astype(np.float32)
fft_row = (20.0 * np.log10(mag + 1e-9)).astype(np.float32)
prev_head = (self.head - 1) % self.ring_fft.shape[0]
self.ring_fft[prev_head, :] = fft_row
self.last_fft_vals = fft_row
fr_min = np.nanmin(fft_row)
fr_max = float(np.nanpercentile(fft_row, 90))
if self.y_min_fft is None or (not np.isnan(fr_min) and fr_min < self.y_min_fft):
self.y_min_fft = float(fr_min)
if self.y_max_fft is None or (not np.isnan(fr_max) and fr_max > self.y_max_fft):
self.y_max_fft = float(fr_max)
def get_display_ring(self) -> np.ndarray:
"""Кольцо в порядке от старого к новому, ось времени по X. Форма: (width, time)."""
if self.ring is None: if self.ring is None:
return np.zeros((1, 1), dtype=np.float32) return np.zeros((1, 1), dtype=np.float32)
base = self.ring if self.head == 0 else np.roll(self.ring, -self.head, axis=0) base = self.ring if self.head == 0 else np.roll(self.ring, -self.head, axis=0)
return base.T return base.T # (width, time)
def get_display_raw_decimated(self, max_points: int) -> np.ndarray: def get_display_ring_fft(self) -> np.ndarray:
"""Return a display-oriented raw waterfall with optional frequency decimation.""" """FFT-кольцо в порядке от старого к новому. Форма: (bins, time)."""
if self.ring is None:
return np.zeros((1, 1), dtype=np.float32)
limit = int(max_points)
if limit <= 0 or self.width <= limit:
return self.get_display_raw()
row_order = np.arange(self.ring.shape[0], dtype=np.int64)
if self.head:
row_order = np.roll(row_order, -self.head)
col_idx = np.linspace(0, self.width - 1, limit, dtype=np.int64)
return self.ring[np.ix_(row_order, col_idx)].T
def get_display_fft_linear(self) -> np.ndarray:
if self.ring_fft is None: if self.ring_fft is None:
return np.zeros((1, 1), dtype=np.float32) return np.zeros((1, 1), dtype=np.float32)
base = self.ring_fft if self.head == 0 else np.roll(self.ring_fft, -self.head, axis=0) base = self.ring_fft if self.head == 0 else np.roll(self.ring_fft, -self.head, axis=0)
return base.T return base.T # (bins, time)
def get_last_fft_linear(self) -> Optional[np.ndarray]:
if self.last_fft_mag is None:
return None
return np.asarray(self.last_fft_mag, dtype=np.float32).copy()
def get_display_times(self) -> Optional[np.ndarray]: def get_display_times(self) -> Optional[np.ndarray]:
"""Временные метки строк в порядке от старого к новому."""
if self.ring_time is None: if self.ring_time is None:
return None return None
return self.ring_time if self.head == 0 else np.roll(self.ring_time, -self.head) return self.ring_time if self.head == 0 else np.roll(self.ring_time, -self.head)
def subtract_recent_mean_fft(
self, disp_fft: np.ndarray, spec_mean_sec: float
) -> np.ndarray:
"""Вычесть среднее по каждой частоте за последние spec_mean_sec секунд."""
if spec_mean_sec <= 0.0:
return disp_fft
disp_times = self.get_display_times()
if disp_times is None:
return disp_fft
now_t = time.time()
mask = np.isfinite(disp_times) & (disp_times >= (now_t - spec_mean_sec))
if not np.any(mask):
return disp_fft
try:
mean_spec = np.nanmean(disp_fft[:, mask], axis=1)
except Exception:
return disp_fft
mean_spec = np.nan_to_num(mean_spec, nan=0.0)
return disp_fft - mean_spec[:, None]
def compute_fft_levels(
self, disp_fft: np.ndarray, spec_clip: Optional[Tuple[float, float]]
) -> Optional[Tuple[float, float]]:
"""Вычислить (vmin, vmax) для отображения водопада спектров."""
# 1. По среднему спектру за видимое время
try:
mean_spec = np.nanmean(disp_fft, axis=1)
vmin_v = float(np.nanmin(mean_spec))
vmax_v = float(np.nanmax(mean_spec))
if np.isfinite(vmin_v) and np.isfinite(vmax_v) and vmin_v != vmax_v:
return (vmin_v, vmax_v)
except Exception:
pass
# 2. Процентильная обрезка
if spec_clip is not None:
try:
vmin_v = float(np.nanpercentile(disp_fft, spec_clip[0]))
vmax_v = float(np.nanpercentile(disp_fft, spec_clip[1]))
if np.isfinite(vmin_v) and np.isfinite(vmax_v) and vmin_v != vmax_v:
return (vmin_v, vmax_v)
except Exception:
pass
# 3. Глобальные накопленные мин/макс
if (
self.y_min_fft is not None
and self.y_max_fft is not None
and np.isfinite(self.y_min_fft)
and np.isfinite(self.y_max_fft)
and self.y_min_fft != self.y_max_fft
):
return (self.y_min_fft, self.y_max_fft)
return None

View File

@ -1,54 +0,0 @@
"""Mutable state container for the PyQtGraph backend."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, List, Optional
import numpy as np
from rfg_adc_plotter.constants import BACKGROUND_MEDIAN_SWEEPS
from rfg_adc_plotter.state.background_buffer import BackgroundMedianBuffer
from rfg_adc_plotter.state.ring_buffer import RingBuffer
from rfg_adc_plotter.types import SweepAuxCurves, SweepInfo
@dataclass
class RuntimeState:
ring: RingBuffer
range_min_ghz: float = 0.0
range_max_ghz: float = 0.0
full_current_freqs: Optional[np.ndarray] = None
full_current_sweep_raw: Optional[np.ndarray] = None
full_current_sweep_codes: Optional[np.ndarray] = None
full_current_fft_source: Optional[np.ndarray] = None
full_current_aux_curves: SweepAuxCurves = None
full_current_aux_curves_codes: SweepAuxCurves = None
current_freqs: Optional[np.ndarray] = None
current_distances: Optional[np.ndarray] = None
current_sweep_raw: Optional[np.ndarray] = None
current_fft_source: Optional[np.ndarray] = None
current_fft_input: Optional[np.ndarray] = None
current_fft_complex: Optional[np.ndarray] = None
current_aux_curves: SweepAuxCurves = None
current_sweep_norm: Optional[np.ndarray] = None
current_fft_mag: Optional[np.ndarray] = None
current_fft_db: Optional[np.ndarray] = None
last_calib_sweep: Optional[np.ndarray] = None
calib_envelope: Optional[np.ndarray] = None
calib_file_path: Optional[str] = None
complex_calib_curve: Optional[np.ndarray] = None
complex_calib_file_path: Optional[str] = None
background_buffer: BackgroundMedianBuffer = field(
default_factory=lambda: BackgroundMedianBuffer(BACKGROUND_MEDIAN_SWEEPS)
)
background_profile: Optional[np.ndarray] = None
background_file_path: Optional[str] = None
current_info: Optional[SweepInfo] = None
current_peak_width: Optional[float] = None
current_peak_amplitude: Optional[float] = None
peak_candidates: List[Dict[str, float]] = field(default_factory=list)
plot_dirty: bool = False
def mark_dirty(self) -> None:
self.plot_dirty = True

View File

@ -1,34 +1,7 @@
"""Shared runtime and parser types.""" from typing import Any, Dict, Tuple, Union
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Literal, Optional, Tuple, TypeAlias, Union
import numpy as np import numpy as np
Number = Union[int, float] Number = Union[int, float]
SignalKind = Literal["bin_iq", "bin_logdet"]
SweepInfo = Dict[str, Any] SweepInfo = Dict[str, Any]
SweepData = Dict[str, np.ndarray] SweepPacket = Tuple[np.ndarray, SweepInfo]
SweepAuxCurves = Optional[Tuple[np.ndarray, np.ndarray]]
SweepPacket = Tuple[np.ndarray, SweepInfo, SweepAuxCurves]
@dataclass(frozen=True)
class StartEvent:
ch: Optional[int] = None
signal_kind: Optional[SignalKind] = None
@dataclass(frozen=True)
class PointEvent:
ch: int
x: int
y: float
aux: Optional[Tuple[float, float]] = None
signal_kind: Optional[SignalKind] = None
ParserEvent: TypeAlias = Union[StartEvent, PointEvent]

2
run_dataplotter Executable file
View File

@ -0,0 +1,2 @@
#!/usr/bin/bash
python3 -m rfg_adc_plotter.main --bin --backend mpl $@

View File

@ -1,44 +0,0 @@
from __future__ import annotations
import numpy as np
import unittest
from rfg_adc_plotter.state.background_buffer import BackgroundMedianBuffer
class BackgroundMedianBufferTests(unittest.TestCase):
def test_buffer_returns_median_for_partial_fill(self):
buffer = BackgroundMedianBuffer(max_rows=4)
buffer.push(np.asarray([1.0, 5.0, 9.0], dtype=np.float32))
buffer.push(np.asarray([3.0, 7.0, 11.0], dtype=np.float32))
median = buffer.median()
self.assertIsNotNone(median)
self.assertTrue(np.allclose(median, np.asarray([2.0, 6.0, 10.0], dtype=np.float32)))
def test_buffer_wraparound_keeps_latest_rows(self):
buffer = BackgroundMedianBuffer(max_rows=2)
buffer.push(np.asarray([1.0, 5.0], dtype=np.float32))
buffer.push(np.asarray([3.0, 7.0], dtype=np.float32))
buffer.push(np.asarray([9.0, 11.0], dtype=np.float32))
median = buffer.median()
self.assertIsNotNone(median)
self.assertTrue(np.allclose(median, np.asarray([6.0, 9.0], dtype=np.float32)))
def test_buffer_reset_clears_state(self):
buffer = BackgroundMedianBuffer(max_rows=2)
buffer.push(np.asarray([1.0, 2.0], dtype=np.float32))
buffer.reset()
self.assertIsNone(buffer.rows)
self.assertIsNone(buffer.median())
self.assertEqual(buffer.count, 0)
self.assertEqual(buffer.head, 0)
if __name__ == "__main__":
unittest.main()

View File

@ -1,57 +0,0 @@
from __future__ import annotations
import subprocess
import sys
import unittest
from pathlib import Path
from rfg_adc_plotter.cli import build_parser
ROOT = Path(__file__).resolve().parents[1]
def _run(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, *args],
cwd=ROOT,
text=True,
capture_output=True,
check=False,
)
class CliTests(unittest.TestCase):
def test_logscale_and_opengl_are_opt_in(self):
args = build_parser().parse_args(["/dev/null"])
self.assertFalse(args.logscale)
self.assertFalse(args.opengl)
self.assertAlmostEqual(float(args.tty_range_v), 5.0, places=6)
args_log = build_parser().parse_args(["/dev/null", "--logscale", "--opengl", "--tty-range-v", "2.5"])
self.assertTrue(args_log.logscale)
self.assertTrue(args_log.opengl)
self.assertAlmostEqual(float(args_log.tty_range_v), 2.5, places=6)
def test_wrapper_help_works(self):
proc = _run("RFG_ADC_dataplotter.py", "--help")
self.assertEqual(proc.returncode, 0)
self.assertIn("usage:", proc.stdout)
self.assertIn("--peak_search", proc.stdout)
def test_module_help_works(self):
proc = _run("-m", "rfg_adc_plotter.main", "--help")
self.assertEqual(proc.returncode, 0)
self.assertIn("usage:", proc.stdout)
self.assertIn("--parser_16_bit_x2", proc.stdout)
self.assertIn("--parser_complex_ascii", proc.stdout)
self.assertIn("--opengl", proc.stdout)
def test_backend_mpl_reports_removal(self):
proc = _run("-m", "rfg_adc_plotter.main", "/dev/null", "--backend", "mpl")
self.assertNotEqual(proc.returncode, 0)
self.assertIn("Matplotlib backend removed", proc.stderr)
if __name__ == "__main__":
unittest.main()

View File

@ -1,694 +0,0 @@
from __future__ import annotations
import os
import tempfile
import numpy as np
import unittest
from rfg_adc_plotter.constants import C_M_S, FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ
from rfg_adc_plotter.gui.pyqtgraph_backend import (
apply_distance_cut_to_axis,
apply_working_range,
apply_working_range_to_aux_curves,
build_logdet_voltage_fft_input,
build_main_window_layout,
coalesce_packets_for_ui,
compute_background_subtracted_bscan_levels,
compute_aux_phase_curve,
convert_tty_i16_to_voltage,
decimate_curve_for_display,
resolve_axis_bounds,
resolve_heavy_refresh_stride,
resolve_initial_window_size,
resolve_distance_cut_start,
sanitize_curve_data_for_display,
sanitize_image_for_display,
set_image_rect_if_ready,
resolve_visible_fft_curves,
resolve_visible_aux_curves,
)
from rfg_adc_plotter.processing.calibration import (
build_calib_envelope,
build_complex_calibration_curve,
calibrate_freqs,
load_calib_envelope,
load_complex_calibration,
recalculate_calibration_c,
save_calib_envelope,
save_complex_calibration,
)
from rfg_adc_plotter.processing.background import (
load_fft_background,
save_fft_background,
subtract_fft_background,
)
from rfg_adc_plotter.processing.fft import (
build_positive_only_exact_centered_ifft_spectrum,
build_positive_only_centered_ifft_spectrum,
build_symmetric_ifft_spectrum,
compute_distance_axis,
compute_fft_complex_row,
compute_fft_mag_row,
compute_fft_row,
fft_mag_to_db,
)
from rfg_adc_plotter.processing.normalization import (
build_calib_envelopes,
fit_complex_calibration_to_width,
normalize_by_calib,
normalize_by_complex_calibration,
normalize_by_envelope,
resample_envelope,
)
from rfg_adc_plotter.processing.peaks import find_peak_width_markers, find_top_peaks_over_ref, rolling_median_ref
class ProcessingTests(unittest.TestCase):
def test_convert_tty_i16_to_voltage_maps_and_clips_full_range(self):
codes = np.asarray([-32768.0, -16384.0, 0.0, 16384.0, 32767.0], dtype=np.float32)
volts = convert_tty_i16_to_voltage(codes, 5.0)
self.assertEqual(volts.shape, codes.shape)
self.assertAlmostEqual(float(volts[0]), -5.0, places=6)
self.assertAlmostEqual(float(volts[2]), 0.0, places=6)
self.assertAlmostEqual(float(volts[-1]), 5.0, places=6)
self.assertTrue(np.all(volts >= -5.0))
self.assertTrue(np.all(volts <= 5.0))
def test_build_logdet_voltage_fft_input_converts_codes_and_exponentiates(self):
codes = np.asarray([-32768.0, 0.0, 32767.0], dtype=np.float32)
volts, fft_input = build_logdet_voltage_fft_input(codes, 5.0)
self.assertEqual(volts.shape, codes.shape)
self.assertEqual(fft_input.shape, codes.shape)
self.assertAlmostEqual(float(volts[0]), -5.0, places=6)
self.assertAlmostEqual(float(volts[1]), 0.0, places=6)
self.assertAlmostEqual(float(volts[2]), 5.0, places=6)
self.assertTrue(np.allclose(fft_input, np.exp(volts.astype(np.float32))))
def test_build_logdet_voltage_fft_input_clips_exp_argument_and_respects_range(self):
codes = np.asarray([32767.0], dtype=np.float32)
volts_5, fft_5 = build_logdet_voltage_fft_input(codes, 5.0, exp_input_limit=2.0)
volts_10, fft_10 = build_logdet_voltage_fft_input(codes, 10.0, exp_input_limit=2.0)
self.assertAlmostEqual(float(volts_5[0]), 5.0, places=6)
self.assertAlmostEqual(float(volts_10[0]), 10.0, places=6)
self.assertAlmostEqual(float(fft_5[0]), float(np.exp(np.float32(2.0))), places=5)
self.assertAlmostEqual(float(fft_10[0]), float(np.exp(np.float32(2.0))), places=5)
self.assertTrue(np.isfinite(fft_5[0]))
self.assertTrue(np.isfinite(fft_10[0]))
def test_recalculate_calibration_preserves_requested_edges(self):
coeffs = recalculate_calibration_c(np.asarray([0.0, 1.0, 0.025], dtype=np.float64), 3.3, 14.3)
y0 = coeffs[0] + coeffs[1] * 3.3 + coeffs[2] * (3.3 ** 2)
y1 = coeffs[0] + coeffs[1] * 14.3 + coeffs[2] * (14.3 ** 2)
self.assertTrue(np.isclose(y0, 3.3))
self.assertTrue(np.isclose(y1, 14.3))
def test_calibrate_freqs_returns_monotonic_axis_and_same_shape(self):
sweep = {"F": np.linspace(3.3, 14.3, 32), "I": np.linspace(-1.0, 1.0, 32)}
calibrated = calibrate_freqs(sweep)
self.assertEqual(calibrated["F"].shape, (32,))
self.assertEqual(calibrated["I"].shape, (32,))
self.assertTrue(np.all(np.diff(calibrated["F"]) >= 0.0))
def test_calibrate_freqs_keeps_complex_payload(self):
sweep = {
"F": np.linspace(3.3, 14.3, 32),
"I": np.exp(1j * np.linspace(0.0, np.pi, 32)).astype(np.complex64),
}
calibrated = calibrate_freqs(sweep)
self.assertEqual(calibrated["F"].shape, (32,))
self.assertEqual(calibrated["I"].shape, (32,))
self.assertTrue(np.iscomplexobj(calibrated["I"]))
self.assertTrue(np.all(np.isfinite(calibrated["I"])))
def test_normalizers_and_envelopes_return_finite_ranges(self):
calib = (np.sin(np.linspace(0.0, 4.0 * np.pi, 64)) * 5.0).astype(np.float32)
raw = calib * 0.75
lower, upper = build_calib_envelopes(calib)
self.assertEqual(lower.shape, calib.shape)
self.assertEqual(upper.shape, calib.shape)
self.assertTrue(np.all(lower <= upper))
self.assertTrue(np.all(np.isfinite(upper)))
self.assertLess(
float(np.mean(np.abs(np.diff(upper, n=2)))),
float(np.mean(np.abs(np.diff(calib, n=2)))),
)
simple = normalize_by_calib(raw, calib + 10.0, norm_type="simple")
projector = normalize_by_calib(raw, calib, norm_type="projector")
self.assertEqual(simple.shape, raw.shape)
self.assertEqual(projector.shape, raw.shape)
self.assertTrue(np.any(np.isfinite(simple)))
self.assertTrue(np.any(np.isfinite(projector)))
def test_file_calibration_envelope_roundtrip_and_division(self):
raw = (np.sin(np.linspace(0.0, 8.0 * np.pi, 128)) * 50.0 + 100.0).astype(np.float32)
envelope = build_calib_envelope(raw)
normalized = normalize_by_envelope(raw, envelope)
resampled = resample_envelope(envelope, 96)
self.assertEqual(envelope.shape, raw.shape)
self.assertEqual(normalized.shape, raw.shape)
self.assertEqual(resampled.shape, (96,))
self.assertTrue(np.any(np.isfinite(normalized)))
self.assertTrue(np.all(np.isfinite(envelope)))
with tempfile.TemporaryDirectory() as tmp_dir:
path = os.path.join(tmp_dir, "calibration_envelope")
saved_path = save_calib_envelope(path, envelope)
loaded = load_calib_envelope(saved_path)
self.assertTrue(saved_path.endswith(".npy"))
self.assertTrue(np.allclose(loaded, envelope))
def test_normalize_by_envelope_adds_small_epsilon_to_zero_denominator(self):
raw = np.asarray([1.0, 2.0, 3.0], dtype=np.float32)
envelope = np.asarray([0.0, 1.0, -1.0], dtype=np.float32)
normalized = normalize_by_envelope(raw, envelope)
self.assertTrue(np.all(np.isfinite(normalized)))
self.assertGreater(normalized[0], 1e8)
self.assertAlmostEqual(float(normalized[1]), 2.0, places=5)
self.assertAlmostEqual(float(normalized[2]), -3.0, places=5)
def test_normalize_by_envelope_supports_complex_input(self):
raw = np.asarray([1.0 + 1.0j, 2.0 - 2.0j], dtype=np.complex64)
envelope = np.asarray([1.0, 2.0], dtype=np.float32)
normalized = normalize_by_envelope(raw, envelope)
self.assertTrue(np.iscomplexobj(normalized))
self.assertTrue(np.all(np.isfinite(normalized)))
self.assertTrue(np.allclose(normalized, np.asarray([1.0 + 1.0j, 1.0 - 1.0j], dtype=np.complex64)))
def test_load_calib_envelope_rejects_empty_payload(self):
with tempfile.TemporaryDirectory() as tmp_dir:
path = os.path.join(tmp_dir, "empty.npy")
np.save(path, np.zeros((0,), dtype=np.float32))
with self.assertRaises(ValueError):
load_calib_envelope(path)
def test_complex_calibration_curve_roundtrip(self):
ch1 = np.asarray([1.0, 2.0, 3.0], dtype=np.float32)
ch2 = np.asarray([0.5, -1.0, 4.0], dtype=np.float32)
curve = build_complex_calibration_curve(ch1, ch2)
expected = np.asarray([1.0 + 0.5j, 2.0 - 1.0j, 3.0 + 4.0j], dtype=np.complex64)
self.assertTrue(np.iscomplexobj(curve))
self.assertTrue(np.allclose(curve, expected))
with tempfile.TemporaryDirectory() as tmp_dir:
path = os.path.join(tmp_dir, "complex_calibration")
saved_path = save_complex_calibration(path, curve)
loaded = load_complex_calibration(saved_path)
self.assertTrue(saved_path.endswith(".npy"))
self.assertEqual(loaded.dtype, np.complex64)
self.assertTrue(np.allclose(loaded, expected))
def test_fit_complex_calibration_to_width_pads_or_trims(self):
calib = np.asarray([1.0 + 1.0j, 2.0 + 2.0j], dtype=np.complex64)
padded = fit_complex_calibration_to_width(calib, 4)
trimmed = fit_complex_calibration_to_width(
np.asarray([1.0 + 1.0j, 2.0 + 2.0j, 3.0 + 3.0j], dtype=np.complex64),
2,
)
self.assertEqual(padded.shape, (4,))
self.assertTrue(np.allclose(padded, np.asarray([1.0 + 1.0j, 2.0 + 2.0j, 1.0 + 0.0j, 1.0 + 0.0j], dtype=np.complex64)))
self.assertEqual(trimmed.shape, (2,))
self.assertTrue(np.allclose(trimmed, np.asarray([1.0 + 1.0j, 2.0 + 2.0j], dtype=np.complex64)))
def test_normalize_by_complex_calibration_handles_zero_and_length_mismatch(self):
signal = np.asarray([2.0 + 2.0j, 4.0 + 0.0j, 3.0 + 3.0j], dtype=np.complex64)
calib = np.asarray([1.0 + 1.0j, 0.0 + 0.0j], dtype=np.complex64)
normalized = normalize_by_complex_calibration(signal, calib)
expected = np.asarray([2.0 + 0.0j, 4.0 + 0.0j, 3.0 + 3.0j], dtype=np.complex64)
self.assertTrue(np.iscomplexobj(normalized))
self.assertTrue(np.all(np.isfinite(normalized)))
self.assertTrue(np.allclose(normalized, expected))
def test_fft_background_roundtrip_and_rejects_non_1d_payload(self):
background = np.asarray([0.5, 1.5, 2.5], dtype=np.float32)
with tempfile.TemporaryDirectory() as tmp_dir:
path = os.path.join(tmp_dir, "fft_background")
saved_path = save_fft_background(path, background)
loaded = load_fft_background(saved_path)
self.assertTrue(saved_path.endswith(".npy"))
self.assertTrue(np.allclose(loaded, background))
invalid_path = os.path.join(tmp_dir, "fft_background_invalid.npy")
np.save(invalid_path, np.zeros((2, 2), dtype=np.float32))
with self.assertRaises(ValueError):
load_fft_background(invalid_path)
def test_subtract_fft_background_clamps_negative_residuals_to_zero(self):
signal = np.asarray([1.0, 2.0, 3.0], dtype=np.float32)
background = np.asarray([1.0, 1.5, 5.0], dtype=np.float32)
subtracted = subtract_fft_background(signal, background)
self.assertTrue(np.allclose(subtracted, np.asarray([0.0, 0.5, 0.0], dtype=np.float32)))
self.assertTrue(np.allclose(subtract_fft_background(signal, signal), 0.0))
def test_apply_working_range_crops_sweep_to_selected_band(self):
freqs = np.linspace(3.3, 14.3, 12, dtype=np.float64)
sweep = np.arange(12, dtype=np.float32)
cropped_freqs, cropped_sweep = apply_working_range(freqs, sweep, 5.0, 9.0)
self.assertGreater(cropped_freqs.size, 0)
self.assertEqual(cropped_freqs.shape, cropped_sweep.shape)
self.assertGreaterEqual(float(np.min(cropped_freqs)), 5.0)
self.assertLessEqual(float(np.max(cropped_freqs)), 9.0)
def test_apply_working_range_returns_empty_when_no_points_match(self):
freqs = np.linspace(3.3, 14.3, 12, dtype=np.float64)
sweep = np.arange(12, dtype=np.float32)
cropped_freqs, cropped_sweep = apply_working_range(freqs, sweep, 20.0, 21.0)
self.assertEqual(cropped_freqs.shape, (0,))
self.assertEqual(cropped_sweep.shape, (0,))
def test_apply_working_range_to_aux_curves_uses_same_mask_as_raw_sweep(self):
freqs = np.linspace(3.3, 14.3, 6, dtype=np.float64)
sweep = np.asarray([0.0, 1.0, np.nan, 3.0, 4.0, 5.0], dtype=np.float32)
aux = (
np.asarray([10.0, 11.0, 12.0, 13.0, 14.0, 15.0], dtype=np.float32),
np.asarray([20.0, 21.0, 22.0, 23.0, 24.0, 25.0], dtype=np.float32),
)
cropped_freqs, cropped_sweep = apply_working_range(freqs, sweep, 4.0, 12.5)
cropped_aux = apply_working_range_to_aux_curves(freqs, sweep, aux, 4.0, 12.5)
self.assertIsNotNone(cropped_aux)
self.assertEqual(cropped_aux[0].shape, cropped_freqs.shape)
self.assertEqual(cropped_aux[1].shape, cropped_freqs.shape)
self.assertEqual(cropped_aux[0].shape, cropped_sweep.shape)
self.assertTrue(np.allclose(cropped_aux[0], np.asarray([11.0, 13.0, 14.0], dtype=np.float32)))
self.assertTrue(np.allclose(cropped_aux[1], np.asarray([21.0, 23.0, 24.0], dtype=np.float32)))
def test_resolve_visible_aux_curves_obeys_checkbox_state(self):
aux = (
np.asarray([1.0, 2.0], dtype=np.float32),
np.asarray([3.0, 4.0], dtype=np.float32),
)
self.assertIsNone(resolve_visible_aux_curves(aux, enabled=False))
visible = resolve_visible_aux_curves(aux, enabled=True)
self.assertIsNotNone(visible)
self.assertTrue(np.allclose(visible[0], aux[0]))
self.assertTrue(np.allclose(visible[1], aux[1]))
def test_compute_aux_phase_curve_returns_atan2_of_aux_channels(self):
aux = (
np.asarray([1.0, 1.0, -1.0, 0.0], dtype=np.float32),
np.asarray([0.0, 1.0, 1.0, 1.0], dtype=np.float32),
)
phase = compute_aux_phase_curve(aux)
self.assertIsNotNone(phase)
expected = np.asarray([0.0, np.pi / 4.0, 3.0 * np.pi / 4.0, np.pi / 2.0], dtype=np.float32)
self.assertEqual(phase.shape, expected.shape)
self.assertTrue(np.allclose(phase, expected, atol=1e-6))
def test_decimate_curve_for_display_preserves_small_series(self):
xs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
ys = np.linspace(-1.0, 1.0, 64, dtype=np.float32)
decimated_x, decimated_y = decimate_curve_for_display(xs, ys, max_points=128)
self.assertTrue(np.allclose(decimated_x, xs))
self.assertTrue(np.allclose(decimated_y, ys))
def test_decimate_curve_for_display_limits_points_and_keeps_endpoints(self):
xs = np.linspace(3.3, 14.3, 10000, dtype=np.float64)
ys = np.sin(np.linspace(0.0, 12.0 * np.pi, 10000)).astype(np.float32)
decimated_x, decimated_y = decimate_curve_for_display(xs, ys, max_points=512)
self.assertLessEqual(decimated_x.size, 512)
self.assertEqual(decimated_x.shape, decimated_y.shape)
self.assertAlmostEqual(float(decimated_x[0]), float(xs[0]), places=12)
self.assertAlmostEqual(float(decimated_x[-1]), float(xs[-1]), places=12)
self.assertAlmostEqual(float(decimated_y[0]), float(ys[0]), places=6)
self.assertAlmostEqual(float(decimated_y[-1]), float(ys[-1]), places=6)
def test_coalesce_packets_for_ui_keeps_newest_packets(self):
packets = [
(np.asarray([float(idx)], dtype=np.float32), {"sweep": idx}, None)
for idx in range(6)
]
kept, skipped = coalesce_packets_for_ui(packets, max_packets=2)
self.assertEqual(skipped, 4)
self.assertEqual(len(kept), 2)
self.assertEqual(int(kept[0][1]["sweep"]), 4)
self.assertEqual(int(kept[1][1]["sweep"]), 5)
def test_coalesce_packets_for_ui_never_returns_empty_for_non_empty_input(self):
packets = [
(np.asarray([1.0], dtype=np.float32), {"sweep": 1}, None),
]
kept, skipped = coalesce_packets_for_ui(packets, max_packets=0)
self.assertEqual(skipped, 0)
self.assertEqual(len(kept), 1)
self.assertEqual(int(kept[0][1]["sweep"]), 1)
def test_coalesce_packets_for_ui_switches_to_latest_only_on_large_backlog(self):
packets = [
(np.asarray([float(idx)], dtype=np.float32), {"sweep": idx}, None)
for idx in range(40)
]
kept, skipped = coalesce_packets_for_ui(packets, max_packets=8, backlog_packets=40)
self.assertEqual(skipped, 39)
self.assertEqual(len(kept), 1)
self.assertEqual(int(kept[0][1]["sweep"]), 39)
def test_resolve_heavy_refresh_stride_increases_with_backlog(self):
self.assertEqual(resolve_heavy_refresh_stride(0, max_packets=8), 1)
self.assertEqual(resolve_heavy_refresh_stride(20, max_packets=8), 2)
self.assertEqual(resolve_heavy_refresh_stride(40, max_packets=8), 4)
def test_sanitize_curve_data_for_display_rejects_fully_nonfinite_series(self):
xs, ys = sanitize_curve_data_for_display(
np.asarray([np.nan, np.nan], dtype=np.float64),
np.asarray([np.nan, np.nan], dtype=np.float32),
)
self.assertEqual(xs.shape, (0,))
self.assertEqual(ys.shape, (0,))
def test_sanitize_image_for_display_rejects_fully_nonfinite_frame(self):
data = sanitize_image_for_display(np.full((4, 4), np.nan, dtype=np.float32))
self.assertIsNone(data)
def test_set_image_rect_if_ready_skips_uninitialized_image(self):
class _DummyImageItem:
def __init__(self):
self.calls = 0
def width(self):
return None
def height(self):
return None
def setRect(self, *_args):
self.calls += 1
image_item = _DummyImageItem()
applied = set_image_rect_if_ready(image_item, 0.0, 0.0, 10.0, 1.0)
self.assertFalse(applied)
self.assertEqual(image_item.calls, 0)
def test_resolve_axis_bounds_rejects_nonfinite_ranges(self):
bounds = resolve_axis_bounds(np.asarray([np.nan, np.inf], dtype=np.float64))
self.assertIsNone(bounds)
def test_resolve_distance_cut_start_interpolates_with_percent(self):
axis = np.asarray([0.0, 1.0, 2.0, 3.0], dtype=np.float64)
cut_start = resolve_distance_cut_start(axis, 50.0)
self.assertIsNotNone(cut_start)
self.assertAlmostEqual(float(cut_start), 1.5, places=6)
def test_apply_distance_cut_to_axis_keeps_farthest_point_for_extreme_cut(self):
axis = np.asarray([0.0, 1.0, 2.0, 3.0], dtype=np.float64)
cut_axis, keep_mask = apply_distance_cut_to_axis(axis, 10.0)
self.assertEqual(cut_axis.shape, (1,))
self.assertEqual(keep_mask.shape, axis.shape)
self.assertTrue(bool(keep_mask[-1]))
self.assertAlmostEqual(float(cut_axis[0]), 3.0, places=6)
def test_resolve_initial_window_size_stays_within_small_screen(self):
width, height = resolve_initial_window_size(800, 480)
self.assertLessEqual(width, 800)
self.assertLessEqual(height, 480)
self.assertGreaterEqual(width, 640)
self.assertGreaterEqual(height, 420)
def test_build_main_window_layout_uses_splitter_and_scroll_area(self):
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
try:
from PyQt5 import QtCore, QtWidgets
except Exception as exc: # pragma: no cover - environment-dependent
self.skipTest(f"Qt unavailable: {exc}")
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
main_window = QtWidgets.QWidget()
try:
_layout, splitter, _plot_layout, settings_widget, settings_layout, settings_scroll = build_main_window_layout(
QtCore,
QtWidgets,
main_window,
)
self.assertIsInstance(splitter, QtWidgets.QSplitter)
self.assertIsInstance(settings_scroll, QtWidgets.QScrollArea)
self.assertIs(settings_scroll.widget(), settings_widget)
self.assertIsInstance(settings_layout, QtWidgets.QVBoxLayout)
finally:
main_window.close()
def test_background_subtracted_bscan_levels_ignore_zero_floor(self):
disp_fft_lin = np.zeros((4, 8), dtype=np.float32)
disp_fft_lin[1, 2:6] = np.asarray([0.05, 0.1, 0.5, 2.0], dtype=np.float32)
disp_fft_lin[2, 1:6] = np.asarray([0.08, 0.2, 0.7, 3.0, 9.0], dtype=np.float32)
disp_fft = fft_mag_to_db(disp_fft_lin)
levels = compute_background_subtracted_bscan_levels(disp_fft_lin, disp_fft)
self.assertIsNotNone(levels)
positive_vals = disp_fft[disp_fft_lin > 0.0]
self.assertAlmostEqual(levels[0], float(np.nanpercentile(positive_vals, 15.0)), places=5)
self.assertAlmostEqual(levels[1], float(np.nanpercentile(positive_vals, 99.7)), places=5)
zero_floor = disp_fft[disp_fft_lin == 0.0]
self.assertLess(float(np.nanmax(zero_floor)), levels[0])
def test_background_subtracted_bscan_levels_fallback_when_residuals_too_sparse(self):
disp_fft_lin = np.zeros((3, 4), dtype=np.float32)
disp_fft_lin[1, 2] = 1.0
disp_fft = fft_mag_to_db(disp_fft_lin)
levels = compute_background_subtracted_bscan_levels(disp_fft_lin, disp_fft)
self.assertIsNone(levels)
def test_fft_helpers_return_expected_shapes(self):
sweep = np.sin(np.linspace(0.0, 4.0 * np.pi, 128)).astype(np.float32)
freqs = np.linspace(3.3, 14.3, 128, dtype=np.float64)
mag = compute_fft_mag_row(sweep, freqs, 513)
row = compute_fft_row(sweep, freqs, 513)
axis = compute_distance_axis(freqs, 513)
self.assertEqual(mag.shape, (513,))
self.assertEqual(row.shape, (513,))
self.assertEqual(axis.shape, (513,))
self.assertTrue(np.all(np.diff(axis) >= 0.0))
def test_symmetric_ifft_spectrum_has_zero_gap_and_mirrored_band(self):
sweep = np.linspace(1.0, 2.0, 128, dtype=np.float32)
freqs = np.linspace(4.0, 10.0, 128, dtype=np.float64)
spectrum = build_symmetric_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
self.assertIsNotNone(spectrum)
freq_axis = np.linspace(-10.0, 10.0, FFT_LEN, dtype=np.float64)
neg_idx_all = np.flatnonzero(freq_axis <= (-4.0))
pos_idx_all = np.flatnonzero(freq_axis >= 4.0)
band_len = int(min(neg_idx_all.size, pos_idx_all.size))
neg_idx = neg_idx_all[:band_len]
pos_idx = pos_idx_all[-band_len:]
zero_mask = (freq_axis > (-4.0)) & (freq_axis < 4.0)
self.assertTrue(np.allclose(spectrum[zero_mask], 0.0))
self.assertTrue(np.allclose(spectrum[neg_idx], spectrum[pos_idx][::-1]))
def test_positive_only_centered_spectrum_keeps_zeros_until_positive_min(self):
sweep = np.linspace(1.0, 2.0, 128, dtype=np.float32)
freqs = np.linspace(4.0, 10.0, 128, dtype=np.float64)
spectrum = build_positive_only_centered_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
self.assertIsNotNone(spectrum)
freq_axis = np.linspace(-10.0, 10.0, FFT_LEN, dtype=np.float64)
zero_mask = freq_axis < 4.0
pos_idx = np.flatnonzero(freq_axis >= 4.0)
self.assertTrue(np.allclose(spectrum[zero_mask], 0.0))
self.assertTrue(np.any(np.abs(spectrum[pos_idx]) > 0.0))
def test_positive_only_exact_spectrum_uses_direct_index_insertion_without_window(self):
sweep = np.asarray([1.0, 2.0, 3.0], dtype=np.float32)
freqs = np.asarray([4.0, 5.0, 6.0], dtype=np.float64)
spectrum = build_positive_only_exact_centered_ifft_spectrum(sweep, freqs)
self.assertIsNotNone(spectrum)
df = (6.0 - 4.0) / 2.0
f_shift = np.arange(-6.0, 6.0 + (0.5 * df), df, dtype=np.float64)
idx = np.round((freqs - f_shift[0]) / df).astype(np.int64)
zero_mask = (f_shift > -6.0) & (f_shift < 4.0)
self.assertEqual(int(spectrum.size), int(f_shift.size))
self.assertTrue(np.allclose(spectrum[zero_mask], 0.0))
self.assertTrue(np.allclose(spectrum[idx], sweep))
def test_complex_symmetric_ifft_spectrum_uses_conjugate_mirror(self):
sweep = np.exp(1j * np.linspace(0.0, np.pi, 128)).astype(np.complex64)
freqs = np.linspace(4.0, 10.0, 128, dtype=np.float64)
spectrum = build_symmetric_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
self.assertIsNotNone(spectrum)
freq_axis = np.linspace(-10.0, 10.0, FFT_LEN, dtype=np.float64)
neg_idx_all = np.flatnonzero(freq_axis <= (-4.0))
pos_idx_all = np.flatnonzero(freq_axis >= 4.0)
band_len = int(min(neg_idx_all.size, pos_idx_all.size))
neg_idx = neg_idx_all[:band_len]
pos_idx = pos_idx_all[-band_len:]
self.assertTrue(np.iscomplexobj(spectrum))
self.assertTrue(np.allclose(spectrum[neg_idx], np.conj(spectrum[pos_idx][::-1])))
def test_compute_fft_helpers_accept_complex_input(self):
sweep = np.exp(1j * np.linspace(0.0, 2.0 * np.pi, 128)).astype(np.complex64)
freqs = np.linspace(3.3, 14.3, 128, dtype=np.float64)
complex_row = compute_fft_complex_row(sweep, freqs, 513, mode="positive_only")
mag = compute_fft_mag_row(sweep, freqs, 513, mode="positive_only")
row = compute_fft_row(sweep, freqs, 513, mode="positive_only")
self.assertEqual(complex_row.shape, (513,))
self.assertTrue(np.iscomplexobj(complex_row))
self.assertEqual(mag.shape, (513,))
self.assertEqual(row.shape, (513,))
self.assertTrue(np.allclose(mag, np.abs(complex_row), equal_nan=True))
self.assertTrue(np.any(np.isfinite(mag)))
self.assertTrue(np.any(np.isfinite(row)))
def test_compute_fft_complex_row_positive_only_exact_matches_manual_ifftshift_ifft(self):
sweep = np.asarray([1.0 + 1.0j, 2.0 + 0.0j, 3.0 - 1.0j], dtype=np.complex64)
freqs = np.asarray([4.0, 5.0, 6.0], dtype=np.float64)
bins = 16
row = compute_fft_complex_row(sweep, freqs, bins, mode="positive_only_exact")
df = (6.0 - 4.0) / 2.0
f_shift = np.arange(-6.0, 6.0 + (0.5 * df), df, dtype=np.float64)
manual_shift = np.zeros((f_shift.size,), dtype=np.complex64)
idx = np.round((freqs - f_shift[0]) / df).astype(np.int64)
manual_shift[idx] = sweep
manual_ifft = np.fft.ifft(np.fft.ifftshift(manual_shift))
expected = np.full((bins,), np.nan + 0j, dtype=np.complex64)
expected[: manual_ifft.size] = np.asarray(manual_ifft, dtype=np.complex64)
self.assertEqual(row.shape, (bins,))
self.assertTrue(np.allclose(row, expected, equal_nan=True))
def test_positive_only_exact_distance_axis_uses_exact_grid_geometry(self):
freqs = np.asarray([4.0, 5.0, 6.0], dtype=np.float64)
bins = 8
axis = compute_distance_axis(freqs, bins, mode="positive_only_exact")
# With a small bins budget the exact-mode grid is downsampled so
# internal IFFT length does not exceed visible bins.
df_hz = 2e9
n_shift = int(np.arange(-6.0, 6.0 + 1.0, 2.0, dtype=np.float64).size)
expected_step = C_M_S / (2.0 * n_shift * df_hz)
expected = np.arange(bins, dtype=np.float64) * expected_step
self.assertEqual(axis.shape, (bins,))
self.assertTrue(np.allclose(axis, expected))
def test_positive_only_exact_mode_remains_stable_when_input_points_double(self):
bins = FFT_LEN // 2 + 1
tau_s = 45e-9
freqs_400 = np.linspace(3.3, 14.3, 400, dtype=np.float64)
freqs_800 = np.linspace(3.3, 14.3, 800, dtype=np.float64)
sweep_400 = np.exp(-1j * 2.0 * np.pi * freqs_400 * 1e9 * tau_s).astype(np.complex64)
sweep_800 = np.exp(-1j * 2.0 * np.pi * freqs_800 * 1e9 * tau_s).astype(np.complex64)
mag_400 = compute_fft_mag_row(sweep_400, freqs_400, bins, mode="positive_only_exact")
mag_800 = compute_fft_mag_row(sweep_800, freqs_800, bins, mode="positive_only_exact")
self.assertEqual(mag_400.shape, mag_800.shape)
finite = np.isfinite(mag_400) & np.isfinite(mag_800)
self.assertGreater(int(np.count_nonzero(finite)), int(0.95 * bins))
idx_400 = int(np.nanargmax(mag_400))
idx_800 = int(np.nanargmax(mag_800))
peak_400 = float(np.nanmax(mag_400))
peak_800 = float(np.nanmax(mag_800))
self.assertLess(abs(idx_400 - idx_800), 64)
self.assertGreater(idx_400, 8)
self.assertGreater(idx_800, 8)
self.assertLess(idx_400, bins - 8)
self.assertLess(idx_800, bins - 8)
self.assertGreater(peak_400, 0.05)
self.assertGreater(peak_800, 0.05)
def test_resolve_visible_fft_curves_handles_complex_mode(self):
complex_row = np.asarray([1.0 + 2.0j, -3.0 + 4.0j], dtype=np.complex64)
mag = np.abs(complex_row).astype(np.float32)
abs_curve, real_curve, imag_curve = resolve_visible_fft_curves(
complex_row,
mag,
complex_mode=True,
show_abs=True,
show_real=False,
show_imag=True,
)
self.assertTrue(np.allclose(abs_curve, mag))
self.assertIsNone(real_curve)
self.assertTrue(np.allclose(imag_curve, np.asarray([2.0, 4.0], dtype=np.float32)))
def test_resolve_visible_fft_curves_preserves_legacy_abs_mode(self):
mag = np.asarray([1.0, 2.0, 3.0], dtype=np.float32)
abs_curve, real_curve, imag_curve = resolve_visible_fft_curves(
None,
mag,
complex_mode=False,
show_abs=True,
show_real=True,
show_imag=True,
)
self.assertTrue(np.allclose(abs_curve, mag))
self.assertIsNone(real_curve)
self.assertIsNone(imag_curve)
def test_symmetric_distance_axis_uses_windowed_frequency_bounds(self):
freqs = np.linspace(4.0, 10.0, 128, dtype=np.float64)
axis = compute_distance_axis(freqs, 513, mode="symmetric")
df_hz = (2.0 * 10.0 / max(1, FFT_LEN - 1)) * 1e9
expected_step = 299_792_458.0 / (2.0 * FFT_LEN * df_hz)
self.assertEqual(axis.shape, (513,))
self.assertTrue(np.all(np.diff(axis) >= 0.0))
self.assertAlmostEqual(float(axis[1] - axis[0]), expected_step, places=15)
def test_peak_helpers_find_reference_and_peak_boxes(self):
xs = np.linspace(0.0, 10.0, 200)
ys = np.exp(-((xs - 5.0) ** 2) / 0.4) * 10.0 + 1.0
ref = rolling_median_ref(xs, ys, 2.0)
peaks = find_top_peaks_over_ref(xs, ys, ref, top_n=3)
width = find_peak_width_markers(xs, ys)
self.assertEqual(ref.shape, ys.shape)
self.assertEqual(len(peaks), 1)
self.assertGreater(peaks[0]["x"], 4.0)
self.assertLess(peaks[0]["x"], 6.0)
self.assertIsNotNone(width)
self.assertGreater(width["width"], 0.0)
if __name__ == "__main__":
unittest.main()

View File

@ -1,176 +0,0 @@
from __future__ import annotations
import numpy as np
import unittest
import warnings
from unittest.mock import patch
from rfg_adc_plotter.processing.fft import compute_fft_mag_row
from rfg_adc_plotter.state.ring_buffer import RingBuffer
class RingBufferTests(unittest.TestCase):
def test_ring_buffer_initializes_on_first_push(self):
ring = RingBuffer(max_sweeps=4)
sweep = np.linspace(-1.0, 1.0, 64, dtype=np.float32)
ring.push(sweep, np.linspace(3.3, 14.3, 64))
self.assertIsNotNone(ring.ring)
self.assertIsNotNone(ring.ring_fft)
self.assertIsNotNone(ring.ring_time)
self.assertIsNotNone(ring.distance_axis)
self.assertIsNotNone(ring.get_last_fft_linear())
self.assertIsNotNone(ring.last_fft_db)
self.assertEqual(ring.ring.shape[0], 4)
self.assertEqual(ring.ring_fft.shape, (4, ring.fft_bins))
def test_ring_buffer_reallocates_when_sweep_width_grows(self):
ring = RingBuffer(max_sweeps=3)
ring.push(np.ones((32,), dtype=np.float32), np.linspace(3.3, 14.3, 32))
first_width = ring.width
ring.push(np.ones((2048,), dtype=np.float32), np.linspace(3.3, 14.3, 2048))
self.assertGreater(ring.width, first_width)
self.assertIsNotNone(ring.ring)
self.assertEqual(ring.ring.shape, (3, ring.width))
def test_ring_buffer_tracks_latest_fft_and_display_arrays(self):
ring = RingBuffer(max_sweeps=2)
ring.push(np.linspace(0.0, 1.0, 64, dtype=np.float32), np.linspace(3.3, 14.3, 64))
ring.push(np.linspace(1.0, 0.0, 64, dtype=np.float32), np.linspace(3.3, 14.3, 64))
raw = ring.get_display_raw()
fft = ring.get_display_fft_linear()
self.assertEqual(raw.shape[1], 2)
self.assertEqual(fft.shape[1], 2)
self.assertIsNotNone(ring.last_fft_db)
self.assertEqual(ring.last_fft_db.shape, (ring.fft_bins,))
def test_ring_buffer_can_return_decimated_display_raw(self):
ring = RingBuffer(max_sweeps=3)
sweep_a = np.linspace(0.0, 1.0, 4096, dtype=np.float32)
sweep_b = np.linspace(1.0, 2.0, 4096, dtype=np.float32)
sweep_c = np.linspace(2.0, 3.0, 4096, dtype=np.float32)
freqs = np.linspace(3.3, 14.3, 4096, dtype=np.float64)
ring.push(sweep_a, freqs)
ring.push(sweep_b, freqs)
ring.push(sweep_c, freqs)
raw = ring.get_display_raw_decimated(256)
self.assertEqual(raw.shape, (256, 3))
self.assertAlmostEqual(float(raw[0, -1]), float(sweep_c[0]), places=6)
self.assertAlmostEqual(float(raw[-1, -1]), float(sweep_c[-1]), places=6)
def test_ring_buffer_can_switch_fft_mode_and_rebuild_fft_rows(self):
ring = RingBuffer(max_sweeps=2)
sweep = np.linspace(0.0, 1.0, 64, dtype=np.float32)
freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
ring.push(sweep, freqs)
fft_before = ring.last_fft_db.copy()
axis_before = ring.distance_axis.copy()
changed = ring.set_symmetric_fft_enabled(False)
self.assertTrue(changed)
self.assertFalse(ring.fft_symmetric)
self.assertEqual(ring.get_display_raw().shape[1], 2)
self.assertIsNotNone(ring.get_last_fft_linear())
self.assertEqual(ring.last_fft_db.shape, fft_before.shape)
self.assertFalse(np.allclose(ring.last_fft_db, fft_before))
self.assertFalse(np.allclose(ring.distance_axis, axis_before))
def test_ring_buffer_can_switch_to_positive_only_fft_mode(self):
ring = RingBuffer(max_sweeps=2)
sweep = np.linspace(0.0, 1.0, 64, dtype=np.float32)
freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
ring.push(sweep, freqs)
changed = ring.set_fft_mode("positive_only")
self.assertTrue(changed)
self.assertEqual(ring.fft_mode, "positive_only")
self.assertIsNotNone(ring.last_fft_db)
self.assertEqual(ring.last_fft_db.shape, (ring.fft_bins,))
self.assertIsNotNone(ring.distance_axis)
def test_ring_buffer_can_switch_to_positive_only_exact_fft_mode(self):
ring = RingBuffer(max_sweeps=2)
sweep = np.linspace(0.0, 1.0, 64, dtype=np.float32)
freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
ring.push(sweep, freqs)
changed = ring.set_fft_mode("positive_only_exact")
self.assertTrue(changed)
self.assertEqual(ring.fft_mode, "positive_only_exact")
self.assertIsNotNone(ring.last_fft_db)
self.assertEqual(ring.last_fft_db.shape, (ring.fft_bins,))
self.assertIsNotNone(ring.distance_axis)
def test_ring_buffer_rebuilds_fft_from_complex_input(self):
ring = RingBuffer(max_sweeps=2)
freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
complex_input = np.exp(1j * np.linspace(0.0, 2.0 * np.pi, 64)).astype(np.complex64)
display_sweep = np.abs(complex_input).astype(np.float32)
ring.push(display_sweep, freqs, fft_input=complex_input)
ring.set_fft_mode("direct")
expected = compute_fft_mag_row(complex_input, freqs, ring.fft_bins, mode="direct")
self.assertTrue(np.allclose(ring.get_last_fft_linear(), expected))
self.assertFalse(np.iscomplexobj(ring.get_display_fft_linear()))
self.assertTrue(np.allclose(ring.get_display_raw()[: display_sweep.size, -1], display_sweep))
def test_ring_buffer_reset_clears_cached_history(self):
ring = RingBuffer(max_sweeps=2)
ring.push(np.linspace(0.0, 1.0, 64, dtype=np.float32), np.linspace(4.0, 10.0, 64))
ring.reset()
self.assertIsNone(ring.ring)
self.assertIsNone(ring.ring_fft)
self.assertIsNone(ring.distance_axis)
self.assertIsNone(ring.last_fft_db)
self.assertEqual(ring.width, 0)
self.assertEqual(ring.head, 0)
def test_ring_buffer_push_ignores_all_nan_fft_without_runtime_warning(self):
ring = RingBuffer(max_sweeps=2)
freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
ring.push(np.linspace(0.0, 1.0, 64, dtype=np.float32), freqs)
fft_before = ring.last_fft_db.copy()
y_min_before = ring.y_min_fft
y_max_before = ring.y_max_fft
with warnings.catch_warnings():
warnings.simplefilter("error", RuntimeWarning)
with patch(
"rfg_adc_plotter.state.ring_buffer.compute_fft_mag_row",
return_value=np.full((ring.fft_bins,), np.nan, dtype=np.float32),
):
ring.push(np.linspace(1.0, 2.0, 64, dtype=np.float32), freqs)
self.assertFalse(ring.last_push_fft_valid)
self.assertTrue(np.allclose(ring.last_fft_db, fft_before))
self.assertEqual(ring.y_min_fft, y_min_before)
self.assertEqual(ring.y_max_fft, y_max_before)
def test_ring_buffer_set_fft_mode_ignores_all_nan_rebuild_without_runtime_warning(self):
ring = RingBuffer(max_sweeps=2)
freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
ring.push(np.linspace(0.0, 1.0, 64, dtype=np.float32), freqs)
fft_before = ring.last_fft_db.copy()
with warnings.catch_warnings():
warnings.simplefilter("error", RuntimeWarning)
with patch(
"rfg_adc_plotter.state.ring_buffer.compute_fft_mag_row",
return_value=np.full((ring.fft_bins,), np.nan, dtype=np.float32),
):
ring.set_fft_mode("direct")
self.assertFalse(ring.last_push_fft_valid)
self.assertTrue(np.allclose(ring.last_fft_db, fft_before))
self.assertEqual(ring.fft_mode, "direct")
if __name__ == "__main__":
unittest.main()

View File

@ -1,416 +0,0 @@
from __future__ import annotations
import math
import unittest
from rfg_adc_plotter.io.sweep_parser_core import (
AsciiSweepParser,
ComplexAsciiSweepParser,
LegacyBinaryParser,
LogScale16BitX2BinaryParser,
LogScaleBinaryParser32,
ParserTestStreamParser,
PointEvent,
StartEvent,
SweepAssembler,
log_pair_to_sweep,
)
def _u16le(word: int) -> bytes:
w = int(word) & 0xFFFF
return bytes((w & 0xFF, (w >> 8) & 0xFF))
def _pack_legacy_start(ch: int) -> bytes:
return b"\xff\xff" * 3 + bytes((0x0A, int(ch) & 0xFF))
def _pack_legacy_point(ch: int, step: int, value_i32: int) -> bytes:
value = int(value_i32) & 0xFFFF_FFFF
return b"".join(
[
_u16le(step),
_u16le((value >> 16) & 0xFFFF),
_u16le(value & 0xFFFF),
bytes((0x0A, int(ch) & 0xFF)),
]
)
def _pack_log_start(ch: int) -> bytes:
return b"\xff\xff" * 5 + bytes((0x0A, int(ch) & 0xFF))
def _pack_log_point(step: int, avg1: int, avg2: int, ch: int = 0) -> bytes:
a1 = int(avg1) & 0xFFFF_FFFF
a2 = int(avg2) & 0xFFFF_FFFF
return b"".join(
[
_u16le(step),
_u16le((a1 >> 16) & 0xFFFF),
_u16le(a1 & 0xFFFF),
_u16le((a2 >> 16) & 0xFFFF),
_u16le(a2 & 0xFFFF),
bytes((0x0A, int(ch) & 0xFF)),
]
)
def _pack_log16_start(ch: int) -> bytes:
return b"\xff\xff" * 3 + bytes((0x0A, int(ch) & 0xFF))
def _pack_log16_point(step: int, avg1: int, avg2: int) -> bytes:
return b"".join(
[
_u16le(step),
_u16le(avg1),
_u16le(avg2),
_u16le(0xFFFF),
]
)
def _pack_tty_start() -> bytes:
return b"".join([_u16le(0x000A), _u16le(0xFFFF), _u16le(0xFFFF), _u16le(0xFFFF)])
def _pack_tty_point(step: int, ch1: int, ch2: int) -> bytes:
return b"".join(
[
_u16le(0x000A),
_u16le(step),
_u16le(ch1),
_u16le(ch2),
]
)
def _pack_logdet_point(step: int, value: int) -> bytes:
return b"".join(
[
_u16le(0x001A),
_u16le(step),
_u16le(value),
_u16le(0x0000),
]
)
class SweepParserCoreTests(unittest.TestCase):
def test_ascii_parser_emits_start_and_points(self):
parser = AsciiSweepParser()
events = parser.feed(b"Sweep_start\ns 1 2 -3\ns2 4 5\n")
self.assertIsInstance(events[0], StartEvent)
self.assertIsInstance(events[1], PointEvent)
self.assertIsInstance(events[2], PointEvent)
self.assertEqual(events[1].ch, 1)
self.assertEqual(events[1].x, 2)
self.assertEqual(events[1].y, -3.0)
self.assertEqual(events[2].ch, 2)
self.assertEqual(events[2].x, 4)
self.assertEqual(events[2].y, 5.0)
def test_legacy_binary_parser_resynchronizes_after_garbage(self):
parser = LegacyBinaryParser()
stream = b"\x00junk" + _pack_legacy_start(3) + _pack_legacy_point(3, 1, -2)
events = parser.feed(stream)
self.assertIsInstance(events[0], StartEvent)
self.assertEqual(events[0].ch, 3)
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].ch, 3)
self.assertEqual(events[1].x, 1)
self.assertEqual(events[1].y, -2.0)
def test_legacy_binary_parser_detects_new_sweep_on_step_reset(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_legacy_point(3, 1, -2),
_pack_legacy_point(3, 2, -3),
_pack_legacy_point(3, 1, -4),
]
)
events = parser.feed(stream)
self.assertIsInstance(events[0], PointEvent)
self.assertIsInstance(events[1], PointEvent)
self.assertIsInstance(events[2], StartEvent)
self.assertEqual(events[2].ch, 3)
self.assertIsInstance(events[3], PointEvent)
self.assertEqual(events[3].x, 1)
self.assertEqual(events[3].y, -4.0)
def test_legacy_binary_parser_accepts_tty_ch1_ch2_stream(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_tty_start(),
_pack_tty_point(1, 100, 90),
_pack_tty_point(2, 120, 95),
]
)
events = parser.feed(stream)
self.assertIsInstance(events[0], StartEvent)
self.assertEqual(events[0].ch, 0)
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].x, 1)
self.assertEqual(events[1].y, 18100.0)
self.assertEqual(events[1].aux, (100.0, 90.0))
self.assertEqual(events[1].signal_kind, "bin_iq")
self.assertIsInstance(events[2], PointEvent)
self.assertEqual(events[2].x, 2)
self.assertEqual(events[2].y, 23425.0)
self.assertEqual(events[2].aux, (120.0, 95.0))
self.assertEqual(events[2].signal_kind, "bin_iq")
def test_legacy_binary_parser_detects_new_tty_sweep_on_step_reset(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_tty_start(),
_pack_tty_point(1, 100, 90),
_pack_tty_point(2, 110, 95),
_pack_tty_point(1, 120, 80),
]
)
events = parser.feed(stream)
self.assertIsInstance(events[0], StartEvent)
self.assertIsInstance(events[1], PointEvent)
self.assertIsInstance(events[2], PointEvent)
self.assertIsInstance(events[3], StartEvent)
self.assertEqual(events[3].ch, 0)
self.assertIsInstance(events[4], PointEvent)
self.assertEqual(events[4].x, 1)
self.assertEqual(events[4].aux, (120.0, 80.0))
self.assertEqual(events[4].signal_kind, "bin_iq")
def test_legacy_binary_parser_tty_mode_does_not_flip_to_legacy_on_ch2_low_byte_0x0a(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_tty_start(),
_pack_tty_point(1, 100, 0x040A), # low byte is 0x0A: used to be misparsed as legacy
_pack_tty_point(2, 120, 0x0410),
]
)
events = parser.feed(stream)
self.assertEqual(len(events), 3)
self.assertIsInstance(events[0], StartEvent)
self.assertEqual(events[0].ch, 0)
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].ch, 0)
self.assertEqual(events[1].x, 1)
self.assertEqual(events[1].aux, (100.0, 1034.0))
self.assertEqual(events[1].y, 1079156.0)
self.assertIsInstance(events[2], PointEvent)
self.assertEqual(events[2].ch, 0)
self.assertEqual(events[2].x, 2)
self.assertEqual(events[2].aux, (120.0, 1040.0))
self.assertEqual(events[2].y, 1096000.0)
def test_legacy_binary_parser_accepts_logdet_stream(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_logdet_point(1, 0x0F77),
_pack_logdet_point(2, 0xF234),
]
)
events = parser.feed(stream)
self.assertEqual(len(events), 2)
self.assertIsInstance(events[0], PointEvent)
self.assertEqual(events[0].x, 1)
self.assertEqual(events[0].y, 3959.0)
self.assertIsNone(events[0].aux)
self.assertEqual(events[0].signal_kind, "bin_logdet")
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].x, 2)
self.assertEqual(events[1].y, -3532.0)
self.assertEqual(events[1].signal_kind, "bin_logdet")
def test_legacy_binary_parser_splits_packet_on_bin_signal_kind_change(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_tty_start(),
_pack_tty_point(1, 100, 90),
_pack_tty_point(2, 110, 95),
_pack_logdet_point(3, 0x0F77),
]
)
events = parser.feed(stream)
self.assertIsInstance(events[0], StartEvent)
self.assertEqual(events[0].signal_kind, "bin_iq")
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].signal_kind, "bin_iq")
self.assertIsInstance(events[2], PointEvent)
self.assertEqual(events[2].signal_kind, "bin_iq")
self.assertIsInstance(events[3], StartEvent)
self.assertEqual(events[3].signal_kind, "bin_logdet")
self.assertIsInstance(events[4], PointEvent)
self.assertEqual(events[4].x, 3)
self.assertEqual(events[4].signal_kind, "bin_logdet")
def test_complex_ascii_parser_detects_new_sweep_on_step_reset(self):
parser = ComplexAsciiSweepParser()
events = parser.feed(b"0 3 4\n1 5 12\n0 8 15\n")
self.assertIsInstance(events[0], PointEvent)
self.assertEqual(events[0].x, 0)
self.assertEqual(events[0].y, 5.0)
self.assertEqual(events[0].aux, (3.0, 4.0))
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].y, 13.0)
self.assertIsInstance(events[2], StartEvent)
self.assertIsInstance(events[3], PointEvent)
self.assertEqual(events[3].aux, (8.0, 15.0))
def test_logscale_32_parser_keeps_channel_and_aux_values(self):
parser = LogScaleBinaryParser32()
stream = _pack_log_start(5) + _pack_log_point(7, 1500, 700, ch=5)
events = parser.feed(stream)
self.assertIsInstance(events[0], StartEvent)
self.assertEqual(events[0].ch, 5)
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].ch, 5)
self.assertEqual(events[1].x, 7)
self.assertAlmostEqual(events[1].y, log_pair_to_sweep(1500, 700), places=6)
self.assertEqual(events[1].aux, (1500.0, 700.0))
def test_logscale_32_parser_detects_new_sweep_on_step_reset(self):
parser = LogScaleBinaryParser32()
stream = b"".join(
[
_pack_log_point(1, 1500, 700, ch=5),
_pack_log_point(2, 1400, 650, ch=5),
_pack_log_point(1, 1300, 600, ch=5),
]
)
events = parser.feed(stream)
self.assertIsInstance(events[0], PointEvent)
self.assertIsInstance(events[1], PointEvent)
self.assertIsInstance(events[2], StartEvent)
self.assertEqual(events[2].ch, 5)
self.assertIsInstance(events[3], PointEvent)
self.assertEqual(events[3].x, 1)
self.assertAlmostEqual(events[3].y, log_pair_to_sweep(1300, 600), places=6)
def test_log_pair_to_sweep_is_order_independent(self):
self.assertAlmostEqual(log_pair_to_sweep(1500, 700), log_pair_to_sweep(700, 1500), places=6)
def test_logscale_16bit_parser_uses_last_start_channel(self):
parser = LogScale16BitX2BinaryParser()
stream = _pack_log16_start(2) + _pack_log16_point(1, 100, 90)
events = parser.feed(stream)
self.assertIsInstance(events[0], StartEvent)
self.assertEqual(events[0].ch, 2)
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].ch, 2)
self.assertAlmostEqual(events[1].y, math.hypot(100.0, 90.0), places=6)
self.assertEqual(events[1].aux, (100.0, 90.0))
def test_logscale_16bit_parser_detects_new_sweep_on_step_reset(self):
parser = LogScale16BitX2BinaryParser()
stream = b"".join(
[
_pack_log16_start(2),
_pack_log16_point(1, 100, 90),
_pack_log16_point(2, 110, 95),
_pack_log16_point(1, 120, 80),
]
)
events = parser.feed(stream)
self.assertIsInstance(events[0], StartEvent)
self.assertIsInstance(events[1], PointEvent)
self.assertIsInstance(events[2], PointEvent)
self.assertIsInstance(events[3], StartEvent)
self.assertEqual(events[3].ch, 2)
self.assertIsInstance(events[4], PointEvent)
self.assertEqual(events[4].x, 1)
self.assertAlmostEqual(events[4].y, math.hypot(120.0, 80.0), places=6)
def test_parser_test_stream_parser_recovers_point_after_single_separator(self):
parser = ParserTestStreamParser()
stream = b"".join(
[
b"\xff\xff\xff\xff",
bytes((0x0A, 4)),
_u16le(1),
_u16le(100),
_u16le(90),
_u16le(0xFFFF),
]
)
events = parser.feed(stream)
events.extend(parser.feed(_u16le(2)))
self.assertIsInstance(events[0], StartEvent)
self.assertEqual(events[0].ch, 4)
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].ch, 4)
self.assertEqual(events[1].x, 1)
self.assertAlmostEqual(events[1].y, math.hypot(100.0, 90.0), places=6)
self.assertEqual(events[1].aux, (100.0, 90.0))
def test_sweep_assembler_builds_aux_curves_without_inversion(self):
assembler = SweepAssembler(fancy=False, apply_inversion=False)
self.assertIsNone(assembler.consume(StartEvent(ch=1, signal_kind="bin_iq")))
assembler.consume(PointEvent(ch=1, x=1, y=10.0, aux=(100.0, 90.0), signal_kind="bin_iq"))
assembler.consume(PointEvent(ch=1, x=2, y=20.0, aux=(110.0, 95.0), signal_kind="bin_iq"))
sweep, info, aux = assembler.finalize_current()
self.assertEqual(sweep.shape[0], 3)
self.assertEqual(info["ch"], 1)
self.assertEqual(info["signal_kind"], "bin_iq")
self.assertIsNotNone(aux)
self.assertEqual(aux[0][1], 100.0)
self.assertEqual(aux[1][2], 95.0)
def test_sweep_assembler_splits_packet_on_channel_switch(self):
assembler = SweepAssembler(fancy=False, apply_inversion=False)
self.assertIsNone(assembler.consume(PointEvent(ch=1, x=1, y=10.0)))
packet = assembler.consume(PointEvent(ch=2, x=1, y=20.0))
self.assertIsNotNone(packet)
sweep_1, info_1, aux_1 = packet
self.assertIsNone(aux_1)
self.assertEqual(info_1["ch"], 1)
self.assertEqual(info_1["chs"], [1])
self.assertAlmostEqual(float(sweep_1[1]), 10.0, places=6)
sweep_2, info_2, aux_2 = assembler.finalize_current()
self.assertIsNone(aux_2)
self.assertEqual(info_2["ch"], 2)
self.assertEqual(info_2["chs"], [2])
self.assertAlmostEqual(float(sweep_2[1]), 20.0, places=6)
def test_sweep_assembler_splits_packet_on_signal_kind_switch(self):
assembler = SweepAssembler(fancy=False, apply_inversion=False)
self.assertIsNone(assembler.consume(PointEvent(ch=0, x=1, y=10.0, signal_kind="bin_iq")))
packet = assembler.consume(PointEvent(ch=0, x=1, y=20.0, signal_kind="bin_logdet"))
self.assertIsNotNone(packet)
sweep_1, info_1, aux_1 = packet
self.assertIsNone(aux_1)
self.assertEqual(info_1["signal_kind"], "bin_iq")
self.assertAlmostEqual(float(sweep_1[1]), 10.0, places=6)
sweep_2, info_2, aux_2 = assembler.finalize_current()
self.assertIsNone(aux_2)
self.assertEqual(info_2["signal_kind"], "bin_logdet")
self.assertAlmostEqual(float(sweep_2[1]), 20.0, places=6)
if __name__ == "__main__":
unittest.main()

View File

@ -1,262 +0,0 @@
from __future__ import annotations
import contextlib
import io
import threading
import time
import unittest
from queue import Queue
from unittest.mock import patch
from rfg_adc_plotter.io import sweep_reader as sweep_reader_module
from rfg_adc_plotter.io.sweep_reader import SweepReader, _PARSER_16_BIT_X2_PROBE_BYTES
def _u16le(word: int) -> bytes:
value = int(word) & 0xFFFF
return bytes((value & 0xFF, (value >> 8) & 0xFF))
def _pack_legacy_point(ch: int, step: int, value_i32: int) -> bytes:
value = int(value_i32) & 0xFFFF_FFFF
return b"".join(
[
_u16le(step),
_u16le((value >> 16) & 0xFFFF),
_u16le(value & 0xFFFF),
bytes((0x0A, int(ch) & 0xFF)),
]
)
def _pack_log16_start(ch: int) -> bytes:
return b"\xff\xff" * 3 + bytes((0x0A, int(ch) & 0xFF))
def _pack_log16_point(step: int, real: int, imag: int) -> bytes:
return b"".join(
[
_u16le(step),
_u16le(real),
_u16le(imag),
_u16le(0xFFFF),
]
)
def _pack_tty_start() -> bytes:
return b"".join(
[
_u16le(0x000A),
_u16le(0xFFFF),
_u16le(0xFFFF),
_u16le(0xFFFF),
]
)
def _pack_tty_point(step: int, ch1: int, ch2: int) -> bytes:
return b"".join(
[
_u16le(0x000A),
_u16le(step),
_u16le(ch1),
_u16le(ch2),
]
)
def _pack_logdet_point(step: int, value: int) -> bytes:
return b"".join(
[
_u16le(0x001A),
_u16le(step),
_u16le(value),
_u16le(0x0000),
]
)
def _chunk_bytes(data: bytes, size: int = 4096) -> list[bytes]:
return [data[idx : idx + size] for idx in range(0, len(data), size)]
class _FakeSerialLineSource:
def __init__(self, path: str, baud: int, timeout: float = 1.0):
self.path = path
self.baud = baud
self.timeout = timeout
self._using = "fake"
def close(self) -> None:
pass
class _FakeChunkReader:
payload_chunks: list[bytes] = []
def __init__(self, src):
self._src = src
self._chunks = list(type(self).payload_chunks)
def read_available(self) -> bytes:
if self._chunks:
return self._chunks.pop(0)
return b""
class SweepReaderTests(unittest.TestCase):
def _start_reader(self, payload: bytes, **reader_kwargs):
queue: Queue = Queue()
stop_event = threading.Event()
stderr = io.StringIO()
_FakeChunkReader.payload_chunks = _chunk_bytes(payload)
reader = SweepReader(
"/tmp/fake-tty",
115200,
queue,
stop_event,
**reader_kwargs,
)
stack = contextlib.ExitStack()
stack.enter_context(patch.object(sweep_reader_module, "SerialLineSource", _FakeSerialLineSource))
stack.enter_context(patch.object(sweep_reader_module, "SerialChunkReader", _FakeChunkReader))
stack.enter_context(contextlib.redirect_stderr(stderr))
reader.start()
return stack, reader, queue, stop_event, stderr
def test_parser_16_bit_x2_falls_back_to_legacy_stream(self):
payload = bytearray()
while len(payload) < (_PARSER_16_BIT_X2_PROBE_BYTES + 24):
payload += _pack_legacy_point(3, 1, -2)
payload += _pack_legacy_point(3, 2, -3)
payload += _pack_legacy_point(3, 1, -4)
stack, reader, queue, stop_event, stderr = self._start_reader(bytes(payload), parser_16_bit_x2=True)
try:
sweep, info, aux = queue.get(timeout=2.0)
self.assertEqual(info["ch"], 3)
self.assertIsNone(aux)
self.assertGreaterEqual(sweep.shape[0], 3)
self.assertIn("fallback -> legacy", stderr.getvalue())
finally:
stop_event.set()
reader.join(timeout=1.0)
stack.close()
def test_parser_16_bit_x2_falls_back_to_tty_ch1_ch2_stream(self):
payload = bytearray()
while len(payload) < (_PARSER_16_BIT_X2_PROBE_BYTES + 24):
payload += _pack_tty_start()
payload += _pack_tty_point(1, 100, 90)
payload += _pack_tty_point(2, 120, 95)
payload += _pack_tty_point(1, 80, 70)
stack, reader, queue, stop_event, stderr = self._start_reader(bytes(payload), parser_16_bit_x2=True)
try:
sweep, info, aux = queue.get(timeout=2.0)
self.assertEqual(info["ch"], 0)
self.assertIsNotNone(aux)
self.assertGreaterEqual(sweep.shape[0], 3)
self.assertAlmostEqual(float(sweep[1]), 18100.0, places=6)
self.assertAlmostEqual(float(sweep[2]), 23425.0, places=6)
self.assertIn("fallback -> legacy", stderr.getvalue())
finally:
stop_event.set()
reader.join(timeout=1.0)
stack.close()
def test_parser_16_bit_x2_keeps_true_complex_stream(self):
payload = b"".join(
[
_pack_log16_start(2),
_pack_log16_point(1, 3, 4),
_pack_log16_point(2, 5, 12),
_pack_log16_point(1, 8, 15),
]
)
stack, reader, queue, stop_event, stderr = self._start_reader(payload, parser_16_bit_x2=True)
try:
sweep, info, aux = queue.get(timeout=1.0)
self.assertEqual(info["ch"], 2)
self.assertIsNotNone(aux)
self.assertAlmostEqual(float(sweep[1]), 5.0, places=6)
self.assertAlmostEqual(float(sweep[2]), 13.0, places=6)
self.assertNotIn("fallback -> legacy", stderr.getvalue())
finally:
stop_event.set()
reader.join(timeout=1.0)
stack.close()
def test_parser_16_bit_x2_falls_back_to_logdet_1a00_stream(self):
payload = bytearray()
while len(payload) < (_PARSER_16_BIT_X2_PROBE_BYTES + 24):
payload += _pack_logdet_point(1, 0x0F77)
payload += _pack_logdet_point(2, 0x0FCB)
payload += _pack_logdet_point(1, 0x0F88)
stack, reader, queue, stop_event, stderr = self._start_reader(bytes(payload), parser_16_bit_x2=True)
try:
sweep, info, aux = queue.get(timeout=2.0)
self.assertEqual(info["signal_kind"], "bin_logdet")
self.assertIsNone(aux)
self.assertGreaterEqual(sweep.shape[0], 3)
self.assertAlmostEqual(float(sweep[1]), 3959.0, places=6)
self.assertIn("fallback -> legacy", stderr.getvalue())
finally:
stop_event.set()
reader.join(timeout=1.0)
stack.close()
def test_parser_16_bit_x2_probe_inconclusive_logs_hint(self):
payload = b"\x00" * (_PARSER_16_BIT_X2_PROBE_BYTES + 128)
stack, reader, queue, stop_event, stderr = self._start_reader(payload, parser_16_bit_x2=True)
try:
deadline = time.time() + 1.5
logs = ""
while time.time() < deadline:
logs = stderr.getvalue()
if "probe inconclusive" in logs:
break
time.sleep(0.02)
self.assertTrue(queue.empty())
self.assertIn("probe inconclusive", logs)
self.assertIn("try --bin", logs)
finally:
stop_event.set()
reader.join(timeout=1.0)
stack.close()
def test_reader_logs_no_input_warning_when_source_is_idle(self):
with patch.object(sweep_reader_module, "_NO_INPUT_WARN_INTERVAL_S", 0.02), patch.object(
sweep_reader_module, "_NO_PACKET_WARN_INTERVAL_S", 0.02
):
stack, reader, _queue, stop_event, stderr = self._start_reader(b"", parser_16_bit_x2=False)
try:
time.sleep(0.12)
logs = stderr.getvalue()
self.assertIn("no input bytes", logs)
self.assertIn("no sweep packets", logs)
finally:
stop_event.set()
reader.join(timeout=1.0)
stack.close()
def test_reader_join_does_not_raise_when_stopped(self):
stack, reader, _queue, stop_event, _stderr = self._start_reader(b"", parser_16_bit_x2=True)
try:
time.sleep(0.01)
stop_event.set()
reader.join(timeout=1.0)
self.assertFalse(reader.is_alive())
finally:
stop_event.set()
if reader.is_alive():
reader.join(timeout=1.0)
stack.close()
if __name__ == "__main__":
unittest.main()