17 Commits

Author SHA1 Message Date
awe
f89dedf4ab add new data format support 2026-02-10 20:02:55 +03:00
awe
2f6e5d0dda fix median 2026-02-04 14:56:33 +03:00
awe
5978d58dc5 fix sub matplotlib 2026-02-04 14:38:55 +03:00
awe
e0f7678c3e backend upd 2026-02-04 14:13:01 +03:00
awe
60a35b690f fix debug 2026-02-03 19:13:33 +03:00
awe
6f5dfafbfb fix matplotlib 2026-02-03 19:06:26 +03:00
awe
3a072f0298 fix 3 2026-02-03 18:55:39 +03:00
awe
d8b71b2cc4 fix 2 2026-02-03 15:34:37 +03:00
awe
53ff80a522 fix 2026-02-03 15:33:09 +03:00
awe
a32bef2250 reference cli add 2026-02-03 15:24:07 +03:00
awe
61816cf894 reference 2026-02-03 15:10:22 +03:00
awe
3bc2382bd0 new fft 2026-02-03 14:40:39 +03:00
awe
2af6c8a486 new project structure 2026-02-03 14:17:06 +03:00
awe
0332ebdd98 global phase try 2 2026-01-30 15:50:40 +03:00
awe
e84c155e25 try to modern fft 2026-01-30 12:38:17 +03:00
awe
508c835368 add logging 2026-01-29 17:05:47 +03:00
awe
23cff76dd2 test fix for dropping points 2026-01-29 16:58:01 +03:00
44 changed files with 8540 additions and 4320 deletions

8
.gitignore vendored
View File

@ -1,8 +0,0 @@
.venv/
env/
__pycache__/
*.py[cod]
.pytest_cache/
.Python
my_picocom_logfile.txt
sample_data/

282
README.md
View File

@ -1,185 +1,187 @@
# RFG STM32 ADC Receiver GUI
PyQtGraph-приложение для чтения свипов из последовательного порта и отображения:
Реалтайм-плоттер для визуализации данных FMCW радара, получаемых через виртуальный COM-порт от STM32 ADC.
- текущего свипа
- водопада по свипам
- FFT текущего свипа
- B-scan по FFT
## Описание
После рефакторинга проект разделен на пакет `rfg_adc_plotter`. Старый запуск через `RFG_ADC_dataplotter.py` сохранен как совместимый wrapper.
Приложение визуализирует данные в реальном времени, отображая 6 синхронизированных графиков:
## Структура
1. **Сырые данные** - график последнего полученного свипа
2. **Водопад сырых данных** - временная серия последних N свипов
3. **FFT спектр** - спектр текущего свипа в частотной области
4. **B-scan** - спектрограмма (водопад FFT)
5. **Фаза спектра** - развернутая фаза для анализа расстояния
6. **Водопад фазы** - временная эволюция фазы
- `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
## Возможности
## Зависимости
- ✅ Высокопроизводительная визуализация в реальном времени
- ✅ Два бэкенда визуализации: matplotlib (совместимость) и pyqtgraph (скорость)
- ✅ Автоматическая обработка фазы для FMCW радара
- ✅ Преобразование фазы в расстояние
- ✅ Поддержка pyserial или raw TTY доступа
- ✅ Заполнение пропущенных точек (режим --fancy)
- ✅ Инверсия сигнала при отрицательном уровне
- ✅ Диагностика потерь данных
Минимально нужны:
## Установка
### Минимальные требования
```bash
python3 -m venv .venv
. .venv/bin/activate
pip install numpy pyqtgraph PyQt5
pip install -r requirements.txt
```
Если `pyserial` не установлен, приложение попробует открыть порт через raw TTY.
### Зависимости
## Быстрый старт
**Обязательные:**
- `numpy` - обработка массивов и FFT
- `matplotlib` - визуализация
Запуск через старый entrypoint:
**Опциональные (рекомендуется):**
- `pyserial` - доступ к serial порту (обязательно для Windows)
- `pyqtgraph` + `PyQt5` или `PySide6` - быстрый бэкенд визуализации
## Использование
### Базовый запуск
```bash
.venv/bin/python RFG_ADC_dataplotter.py /dev/ttyACM0
python -m rfg_adc_plotter.cli /dev/ttyACM0
```
Запуск напрямую через пакет:
### С параметрами
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0
python -m rfg_adc_plotter.cli /dev/ttyACM0 \
--baud 115200 \
--max-sweeps 200 \
--max-fps 30 \
--backend pg \
--fancy
```
Показать справку:
### Параметры командной строки
- `port` - путь к порту (например `/dev/ttyACM0`, `COM3`)
- `--baud` - скорость порта (по умолчанию 115200)
- `--max-sweeps` - количество свипов в водопаде (по умолчанию 200)
- `--max-fps` - ограничение частоты отрисовки (по умолчанию 30)
- `--cmap` - цветовая карта для водопадов (по умолчанию viridis)
- `--spec-clip` - процентильная обрезка контраста B-scan (по умолчанию 2,98)
- `--title` - заголовок окна (по умолчанию "ADC Sweeps")
- `--fancy` - заполнение пропущенных точек средними значениями
- `--ylim` - фиксированные пределы по Y (формат: min,max)
- `--backend` - бэкенд визуализации:
- `auto` - автоматический выбор (сначала pyqtgraph, fallback на matplotlib)
- `pg` - pyqtgraph (быстрее)
- `mpl` - matplotlib (совместимее)
## Формат данных
Приложение ожидает текстовые строки через serial порт:
```
Sweep_start
s 0 1234
s 1 1256
s 2 1278
...
Sweep_start
s 0 1235
...
```
- `Sweep_start` - начало нового свипа
- `s X Y` - точка данных (индекс X, значение Y), целые числа со знаком
## Архитектура проекта
```
rfg_adc_plotter/
├── __init__.py
├── config.py # Константы и типы
├── cli.py # Точка входа CLI
├── data_acquisition/
│ ├── __init__.py
│ ├── serial_io.py # Serial порт I/O
│ └── sweep_reader.py # Фоновый поток чтения данных
├── signal_processing/
│ ├── __init__.py
│ └── phase_analysis.py # Обработка фазы
├── visualization/
│ ├── __init__.py
│ ├── matplotlib_backend.py # Matplotlib визуализация
│ └── pyqtgraph_backend.py # PyQtGraph визуализация
└── utils/
├── __init__.py
└── formatting.py # Утилиты форматирования
```
## Технические особенности
### Оптимизации производительности
- Фоновый поток для чтения и парсинга данных
- Векторизованные numpy операции
- Кольцевые буферы для водопадов
- Неблокирующее чтение из serial порта
- Буферизация с увеличенным размером (256KB)
### Обработка сигналов
- **FFT анализ**: окно Хэннинга, длина 1024
- **Phase unwrapping**: адаптивный алгоритм с порогом 0.8π
- **Преобразование фазы в расстояние**: формула Δl = φ × c / (4π × ν)
- **Инверсия сигнала**: автоматическая при среднем уровне < порога
### Диагностика
Каждые 10 секунд в stderr выводится диагностическая информация:
- Номер свипа
- Среднее количество валидных точек
- Количество принятых строк
- Ошибки парсинга
- Ошибки чтения
- Размер буфера
- Потерянные свипы
## Примеры использования
### Linux с pyserial
```bash
.venv/bin/python RFG_ADC_dataplotter.py --help
python -m rfg_adc_plotter.cli /dev/ttyACM0 --backend pg
```
## Примеры запуска
Обычный запуск с живого порта:
### Linux с raw TTY (без pyserial)
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --baud 115200
python -m rfg_adc_plotter.cli /dev/ttyACM0 --backend mpl
```
Больше истории в водопаде и ограничение FPS:
### Windows
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --max-sweeps 400 --max-fps 20
python -m rfg_adc_plotter.cli COM3 --backend pg --baud 115200
```
Фиксированный диапазон по оси Y:
### С высоким разрешением времени
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --ylim -1000,1000
python -m rfg_adc_plotter.cli /dev/ttyACM0 --max-sweeps 500 --max-fps 60
```
С включенной нормировкой `simple`:
### С заполнением пропусков и фиксированным Y
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --norm-type simple
python -m rfg_adc_plotter.cli /dev/ttyACM0 --fancy --ylim -2000,2000
```
Режим измерения ширины главного пика FFT:
## Лицензия
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --calibrate
```
См. LICENSE файл в корне проекта.
Поиск топ-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
```
Logscale binary с парой `int32`:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --logscale
```
Logscale 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
```
## Локальная проверка через 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`.
## Проверка и тесты
Синтаксическая проверка:
```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-состояния репозитория.
Разработано для визуализации данных FMCW радара с STM32 ADC.

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()

View File

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

14
requirements.txt Normal file
View File

@ -0,0 +1,14 @@
# Основные зависимости
numpy>=1.20.0
# Визуализация (matplotlib - обязательна)
matplotlib>=3.3.0
# Serial порт (опционально, но рекомендуется)
pyserial>=3.5
# Быстрый бэкенд визуализации (опционально)
pyqtgraph>=0.12.0
PyQt5>=5.15.0
# Альтернатива PyQt5:
# PySide6>=6.0.0

View File

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

View File

@ -1,11 +1,36 @@
"""Command-line parser for the ADC plotter."""
#!/usr/bin/env python3
"""
Точка входа для RFG ADC Data Plotter.
from __future__ import annotations
Реалтайм-плоттер для свипов из виртуального COM-порта.
Формат строк:
- "Sweep_start" — начало нового свипа (предыдущий считается завершённым)
- "s X Y" — точка (индекс X, значение Y), все целые со знаком
Отрисовываются шесть графиков:
- Левый верхний: последний полученный свип (Y vs X)
- Правый верхний: водопад (последние N свипов во времени)
- Левый средний: FFT спектр текущего свипа
- Правый средний: B-scan (водопад FFT спектров)
- Левый нижний: Фаза спектра (развернутая)
- Правый нижний: Водопад фазы
Оптимизации для скорости:
- Парсинг и чтение в фоновой нити
- Анимация с обновлением только данных (без лишнего пересоздания фигур)
- Кольцевой буфер под водопад с фиксированным числом свипов
Зависимости: matplotlib, numpy. PySerial опционален — при его отсутствии
используется сырой доступ к TTY через termios.
"""
import argparse
import sys
def build_parser() -> argparse.ArgumentParser:
def main():
"""Основная функция CLI."""
parser = argparse.ArgumentParser(
description=(
"Читает свипы из виртуального COM-порта и рисует: "
@ -24,19 +49,10 @@ def build_parser() -> argparse.ArgumentParser:
"--spec-clip",
default="2,98",
help=(
"Процентильная обрезка уровней водопада спектров, %% (min,max). "
"Процентильная обрезка уровней водопада спектров, % (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",
@ -52,69 +68,43 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--backend",
choices=["auto", "pg", "mpl"],
default="pg",
help="Совместимый флаг. Поддерживаются только auto и pg; mpl удален.",
default="auto",
help="Графический бэкенд: pyqtgraph (pg) — быстрее; matplotlib (mpl) — совместимый. По умолчанию auto",
)
parser.add_argument(
"--norm-type",
choices=["projector", "simple"],
default="projector",
help="Тип нормировки: projector (по огибающим в [-1,+1]) или simple (raw/calib)",
"--ref-out",
type=str,
default=None,
help="Сохранить медиану последних 1000 свипов в указанный файл при накоплении данных",
)
parser.add_argument(
"--bin",
dest="bin_mode",
action="store_true",
help=(
"Бинарный протокол: старт свипа 0xFFFF,0xFFFF,0xFFFF,(CH<<8)|0x0A; "
"точки step,uint32(hi16,lo16),0x000A"
),
"--ref-in",
type=str,
default=None,
help="Загрузить медиану из файла и вычитать её из входящего сигнала",
)
parser.add_argument(
"--logscale",
action="store_true",
default=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=(
"Бинарный logscale-протокол c парой int16 (avg_1, avg_2): "
"старт 0xFFFF,0xFFFF,0xFFFF,(CH<<8)|0x0A; точка step,avg1_lo16,avg2_lo16,0xFFFF"
),
)
parser.add_argument(
"--parser_test",
action="store_true",
help=(
"Тестовый парсер для формата 16-bit x2: "
"одиночный 0xFFFF завершает точку, серия 0xFFFF начинает новый свип"
),
)
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
args = parser.parse_args()
# Попробуем быстрый бэкенд (pyqtgraph) при auto/pg
if args.backend in ("auto", "pg"):
try:
from .visualization.pyqtgraph_backend import run_pyqtgraph
return run_pyqtgraph(args)
except Exception as e:
if args.backend == "pg":
sys.stderr.write(f"[error] PyQtGraph бэкенд недоступен: {e}\n")
sys.exit(1)
# При auto — тихо откатываемся на matplotlib
# Fallback на matplotlib
try:
from .visualization.matplotlib_backend import run_matplotlib
return run_matplotlib(args)
except Exception as e:
sys.stderr.write(f"[error] Matplotlib бэкенд недоступен: {e}\n")
sys.exit(1)
if __name__ == "__main__":
main()

28
rfg_adc_plotter/config.py Normal file
View File

@ -0,0 +1,28 @@
"""
Константы и типы для RFG ADC Data Plotter.
"""
from typing import Dict, Tuple, Union
import numpy as np
# Максимальное число точек в ряду водопада
WF_WIDTH = 1000
# Длина БПФ для спектра/водопада спектров
FFT_LEN = 2048
# Частотный диапазон для FFT (в ГГц)
FREQ_MIN_GHZ = -10.0 # Начало частотной оси
FREQ_MAX_GHZ = 10.0 # Конец частотной оси
DATA_FREQ_START_GHZ = 1.0 # Начало реальных данных
DATA_FREQ_END_GHZ = 10.0 # Конец реальных данных
# Порог для инверсии сырых данных: если среднее значение свипа ниже порога —
# считаем, что сигнал «меньше нуля» и домножаем свип на -1
DATA_INVERSION_THRASHOLD = 10.0
# Типы данных
Number = Union[int, float]
SweepInfo = Dict[str, Number]
SweepPacket = Tuple[np.ndarray, SweepInfo]

View File

@ -1,17 +0,0 @@
"""Shared constants for sweep parsing and visualization."""
WF_WIDTH = 1000
FFT_LEN = 1024
BACKGROUND_MEDIAN_SWEEPS = 64
SWEEP_FREQ_MIN_GHZ = 3.3
SWEEP_FREQ_MAX_GHZ = 14.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

View File

@ -1,6 +1,6 @@
"""Serial input helpers with pyserial and raw TTY fallbacks."""
from __future__ import annotations
"""
Модули для работы с serial портом: чтение данных через pyserial или raw TTY.
"""
import io
import os
@ -9,24 +9,36 @@ from typing import Optional
def try_open_pyserial(path: str, baud: int, timeout: float):
"""Попытка открыть порт через pyserial."""
try:
import serial # type: ignore
except Exception:
return None
try:
return serial.Serial(path, baudrate=baud, timeout=timeout)
ser = serial.Serial(path, baudrate=baud, timeout=timeout)
# ВРЕМЕННО ОТКЛЮЧЕН: hardware flow control для проверки
# ser.rtscts = True
# Увеличиваем буфер приема ядра до 64KB
try:
ser.set_buffer_size(rx_size=65536, tx_size=4096)
except (AttributeError, NotImplementedError):
# Не все платформы/версии pyserial поддерживают set_buffer_size
pass
return ser
except Exception:
return None
class FDReader:
"""Buffered wrapper around a raw TTY file descriptor."""
"""Простой враппер чтения строк из файлового дескриптора TTY."""
def __init__(self, fd: int):
# Отдельно буферизуем для корректной readline()
self._fd = fd
raw = os.fdopen(fd, "rb", closefd=False)
self._file = raw
self._buf = io.BufferedReader(raw, buffer_size=65536)
# Увеличен размер буфера до 256KB для предотвращения потерь
self._buf = io.BufferedReader(raw, buffer_size=262144)
def fileno(self) -> int:
return self._fd
@ -34,7 +46,7 @@ class FDReader:
def readline(self) -> bytes:
return self._buf.readline()
def close(self) -> None:
def close(self):
try:
self._buf.close()
except Exception:
@ -42,7 +54,10 @@ class 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:
import termios
import tty
@ -56,8 +71,10 @@ def open_raw_tty(path: str, baud: int) -> Optional[FDReader]:
try:
attrs = termios.tcgetattr(fd)
# Установим «сырое» состояние
tty.setraw(fd)
# Скорость
baud_map = {
9600: termios.B9600,
19200: termios.B19200,
@ -67,14 +84,17 @@ def open_raw_tty(path: str, baud: int) -> Optional[FDReader]:
230400: getattr(termios, "B230400", 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[5] = speed
attrs[4] = b # ispeed
attrs[5] = b # ospeed
# VMIN=1, VTIME=0 — блокирующее чтение по байту
cc = attrs[6]
cc[termios.VMIN] = 1
cc[termios.VTIME] = 0
attrs[6] = cc
termios.tcsetattr(fd, termios.TCSANOW, attrs)
except Exception:
try:
@ -87,11 +107,11 @@ def open_raw_tty(path: str, baud: int) -> Optional[FDReader]:
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):
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"
if self._pyserial is None:
self._fdreader = open_raw_tty(path, baud)
@ -107,12 +127,13 @@ class SerialLineSource:
return self._pyserial.readline()
except Exception:
return b""
try:
return self._fdreader.readline() # type: ignore[union-attr]
except Exception:
return b""
else:
try:
return self._fdreader.readline() # type: ignore[union-attr]
except Exception:
return b""
def close(self) -> None:
def close(self):
try:
if self._pyserial is not None:
self._pyserial.close()
@ -123,13 +144,15 @@ class SerialLineSource:
class SerialChunkReader:
"""Fast non-blocking chunk reader for serial sources."""
"""Быстрое неблокирующее чтение чанков из serial/raw TTY для максимального дренажа буфера."""
def __init__(self, src: SerialLineSource):
def __init__(self, src: SerialLineSource, error_counter: Optional[list] = None):
self._src = src
self._ser = src._pyserial
self._fd: Optional[int] = None
self._error_counter = error_counter # Список с 1 элементом для передачи по ссылке
if self._ser is not None:
# Неблокирующий режим для быстрой откачки
try:
self._ser.timeout = 0
except Exception:
@ -145,22 +168,24 @@ class SerialChunkReader:
self._fd = None
def read_available(self) -> bytes:
"""Return currently available bytes or b"" when nothing is ready."""
"""Вернёт доступные байты (b"" если данных нет)."""
if self._ser is not None:
try:
available = int(getattr(self._ser, "in_waiting", 0))
n = int(getattr(self._ser, "in_waiting", 0))
except Exception:
available = 0
if available > 0:
if self._error_counter:
self._error_counter[0] += 1
n = 0
if n > 0:
try:
return self._ser.read(available)
return self._ser.read(n)
except Exception:
if self._error_counter:
self._error_counter[0] += 1
return b""
return b""
if self._fd is None:
return b""
out = bytearray()
while True:
try:
@ -173,5 +198,7 @@ class SerialChunkReader:
except BlockingIOError:
break
except Exception:
if self._error_counter:
self._error_counter[0] += 1
break
return bytes(out)

View File

@ -0,0 +1,269 @@
"""
Фоновый поток для чтения и сборки свипов из serial порта.
"""
import sys
import threading
import time
from collections import deque
from queue import Queue, Full
import numpy as np
from ..config import DATA_INVERSION_THRASHOLD, SweepInfo, SweepPacket
from .serial_io import SerialChunkReader, SerialLineSource
class SweepReader(threading.Thread):
"""Фоновый поток: читает строки, формирует завершённые свипы и кладёт в очередь."""
def __init__(
self,
port_path: str,
baud: int,
out_queue: Queue[SweepPacket],
stop_event: threading.Event,
fancy: bool = False,
):
super().__init__(daemon=True)
self._port_path = port_path
self._baud = baud
self._q = out_queue
self._stop = stop_event
self._src: SerialLineSource | None = None
self._fancy = bool(fancy)
self._max_width: int = 0
self._sweep_idx: int = 0
self._last_sweep_ts: float | None = None
self._n_valid_hist = deque()
# Счетчик потерь данных (выброшенных свипов из-за переполнения очереди)
self._dropped_sweeps: int = 0
# Диагностика потери точек внутри свипа
self._total_lines_received: int = 0 # Всего принято строк с данными
self._total_parse_errors: int = 0 # Ошибок парсинга строк
self._total_empty_lines: int = 0 # Пустых строк
self._max_buf_size: int = 0 # Максимальный размер буфера парсинга
self._read_errors: int = 0 # Ошибок чтения из порта
self._last_diag_time: float = 0.0 # Время последнего вывода диагностики
self._cal_mode: int = -1 # Режим калибровки (07), -1 = неизвестен
def _finalize_current(self, xs, ys, cal_mode: int = -1):
if not xs:
return
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
# Быстрый векторизованный путь
sweep = np.full((target_width,), np.nan, dtype=np.float32)
try:
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))
# Дополнительная обработка пропусков: при --fancy заполняем внутренние разрывы, края и дотягиваем до максимальной длины
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_THRASHOLD:
sweep *= -1.0
except Exception:
pass
sweep -= float(np.nanmean(sweep))
# Метрики для статусной строки (вид словаря: переменная -> значение)
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()
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,
"n_valid": n_valid,
"min": vmin,
"max": vmax,
"mean": mean,
"std": std,
"dt_ms": dt_ms,
"dropped": self._dropped_sweeps,
"lines": self._total_lines_received,
"parse_err": self._total_parse_errors,
"read_err": self._read_errors,
"max_buf": self._max_buf_size,
"cal_mode": cal_mode,
}
# Периодический вывод детальной диагностики в stderr (каждые 10 секунд)
now = time.time()
if now - self._last_diag_time > 10.0:
self._last_diag_time = now
sys.stderr.write(
f"[DIAG] sweep={self._sweep_idx} n_valid={n_valid:.1f} "
f"lines={self._total_lines_received} parse_err={self._total_parse_errors} "
f"read_err={self._read_errors} max_buf={self._max_buf_size} "
f"dropped={self._dropped_sweeps}\n"
)
sys.stderr.flush()
# Кладём готовый свип (если очередь полна — выбрасываем самый старый)
try:
self._q.put_nowait((sweep, info))
except Full:
# Счетчик потерь для диагностики
self._dropped_sweeps += 1
try:
_ = self._q.get_nowait()
except Exception:
pass
try:
self._q.put_nowait((sweep, info))
except Exception:
pass
def run(self):
# Состояние текущего свипа
xs: list[int] = []
ys: list[int] = []
current_cal_mode: int = -1 # Режим калибровки для текущего свипа
try:
self._src = SerialLineSource(self._port_path, self._baud, timeout=1.0)
sys.stderr.write(f"[info] Открыл порт {self._port_path} ({self._src._using})\n")
except Exception as e:
sys.stderr.write(f"[error] {e}\n")
return
try:
# Быстрый неблокирующий дренаж порта с разбором по байтам
# Передаем счетчик ошибок чтения как список для изменения по ссылке
error_counter = [0]
chunk_reader = SerialChunkReader(self._src, error_counter)
buf = bytearray()
while not self._stop.is_set():
data = chunk_reader.read_available()
# Обновляем счетчик ошибок чтения
self._read_errors = error_counter[0]
if data:
buf += data
# Отслеживаем максимальный размер буфера парсинга
if len(buf) > self._max_buf_size:
self._max_buf_size = len(buf)
else:
# Короткая уступка CPU, если нет новых данных (уменьшена до 0.1ms)
time.sleep(0.0001)
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:
self._total_empty_lines += 1
continue
if line.startswith(b"Sweep_start"):
self._finalize_current(xs, ys, current_cal_mode)
xs.clear()
ys.clear()
current_cal_mode = -1
continue
# Формат строки данных: "sN X Y" или "s X Y"
# где N — цифра режима калибровки 07 (слитно с 's')
# X — индекс точки, Y — значение (целое со знаком)
if len(line) >= 3:
parts = line.split()
if parts and len(parts[0]) >= 1 and parts[0][:1].lower() == b"s":
tag = parts[0].lower() # b"s" или b"s0"..b"s7"
if len(tag) == 2 and b"0" <= tag[1:2] <= b"7":
# Новый формат: режим калибровки встроен в тег
current_cal_mode = int(tag[1:2])
data_parts = parts[1:]
elif len(tag) == 1:
# Старый формат: "s X Y"
data_parts = parts[1:]
else:
self._total_parse_errors += 1
continue
if len(data_parts) >= 2:
try:
x = int(data_parts[0], 10)
y = int(data_parts[1], 10)
except Exception:
self._total_parse_errors += 1
continue
xs.append(x)
ys.append(y)
self._total_lines_received += 1
else:
self._total_parse_errors += 1
else:
# Строка не начинается с 's'
self._total_parse_errors += 1
else:
# Строка слишком короткая
self._total_parse_errors += 1
# Защита от переполнения буфера при отсутствии переводов строки (снижен порог)
if len(buf) > 262144:
del buf[:-131072]
finally:
try:
# Завершаем оставшийся свип
self._finalize_current(xs, ys, current_cal_mode)
except Exception:
pass
try:
if self._src is not None:
self._src.close()
except Exception:
pass

View File

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

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,427 +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, 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
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 LegacyBinaryParser:
"""Byte-resynchronizing parser for legacy 8-byte binary records."""
def __init__(self):
self._buf = bytearray()
@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:
w0 = self._u16_at(self._buf, 0)
w1 = self._u16_at(self._buf, 2)
w2 = self._u16_at(self._buf, 4)
if w0 == 0xFFFF and w1 == 0xFFFF and w2 == 0xFFFF and self._buf[6] == 0x0A:
events.append(StartEvent(ch=int(self._buf[7])))
del self._buf[:8]
continue
if self._buf[6] == 0x0A:
ch = int(self._buf[7])
value = u32_to_i32((w1 << 16) | w2)
events.append(PointEvent(ch=ch, x=int(w0), y=float(value)))
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()
@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:
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)
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
@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)
events.append(StartEvent(ch=self._current_channel))
del self._buf[:8]
continue
if words[3] == 0xFFFF and words[0] != 0xFFFF:
avg_1 = u16_to_i16(words[1])
avg_2 = u16_to_i16(words[2])
events.append(
PointEvent(
ch=self._current_channel,
x=int(words[0]),
y=log_pair_to_sweep(avg_1, avg_2),
aux=(float(avg_1), float(avg_2)),
)
)
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
avg_1 = u16_to_i16(int(self._point_buf[1]))
avg_2 = u16_to_i16(int(self._point_buf[2]))
self._expected_step = step + 1
return PointEvent(
ch=self._current_channel,
x=step,
y=log_pair_to_sweep(avg_1, avg_2),
aux=(float(avg_1), float(avg_2)),
)
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_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_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_channels.add(int(event.ch))
return packet
if self._cur_channel is None:
self._cur_channel = int(event.ch)
self._cur_channels.add(int(event.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 None
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,
"n_valid": n_valid,
"min": vmin,
"max": vmax,
"mean": mean,
"std": std,
"dt_ms": dt_ms,
}
return (sweep, info, aux_curves)

View File

@ -1,102 +0,0 @@
"""Background sweep reader thread."""
from __future__ import annotations
import sys
import threading
import time
from queue import Full, Queue
from rfg_adc_plotter.io.serial_source import SerialChunkReader, SerialLineSource
from rfg_adc_plotter.io.sweep_parser_core import (
AsciiSweepParser,
LegacyBinaryParser,
LogScale16BitX2BinaryParser,
LogScaleBinaryParser32,
ParserTestStreamParser,
SweepAssembler,
)
from rfg_adc_plotter.types import SweepPacket
class SweepReader(threading.Thread):
"""Read a serial source in the background and emit completed sweep packets."""
def __init__(
self,
port_path: str,
baud: int,
out_queue: "Queue[SweepPacket]",
stop_event: threading.Event,
fancy: bool = False,
bin_mode: bool = False,
logscale: bool = False,
parser_16_bit_x2: bool = False,
parser_test: bool = False,
):
super().__init__(daemon=True)
self._port_path = port_path
self._baud = int(baud)
self._queue = out_queue
self._stop = stop_event
self._fancy = bool(fancy)
self._bin_mode = bool(bin_mode)
self._logscale = bool(logscale)
self._parser_16_bit_x2 = bool(parser_16_bit_x2)
self._parser_test = bool(parser_test)
self._src: SerialLineSource | None = None
def _build_parser(self):
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)
def _enqueue(self, packet: SweepPacket) -> None:
try:
self._queue.put_nowait(packet)
except Full:
try:
_ = self._queue.get_nowait()
except Exception:
pass
try:
self._queue.put_nowait(packet)
except Exception:
pass
def run(self) -> None:
try:
self._src = SerialLineSource(self._port_path, self._baud, timeout=1.0)
sys.stderr.write(f"[info] Открыл порт {self._port_path} ({self._src._using})\n")
except Exception as exc:
sys.stderr.write(f"[error] {exc}\n")
return
parser, assembler = self._build_parser()
try:
chunk_reader = SerialChunkReader(self._src)
while not self._stop.is_set():
data = chunk_reader.read_available()
if not data:
time.sleep(0.0005)
continue
for event in parser.feed(data):
packet = assembler.consume(event)
if packet is not None:
self._enqueue(packet)
packet = assembler.finalize_current()
if packet is not None:
self._enqueue(packet)
finally:
try:
if self._src is not None:
self._src.close()
except Exception:
pass

View File

@ -1,26 +0,0 @@
"""Main entrypoint for the modularized ADC plotter."""
from __future__ import annotations
import sys
from rfg_adc_plotter.cli import build_parser
def main() -> None:
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)
from rfg_adc_plotter.gui.pyqtgraph_backend import run_pyqtgraph
try:
run_pyqtgraph(args)
except Exception as exc:
sys.stderr.write(f"[error] PyQtGraph бэкенд недоступен: {exc}\n")
raise SystemExit(1) from exc
if __name__ == "__main__":
main()

View File

@ -1,67 +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,
calibrate_freqs,
get_calibration_base,
get_calibration_coeffs,
load_calib_envelope,
recalculate_calibration_c,
save_calib_envelope,
set_calibration_base_value,
)
from rfg_adc_plotter.processing.fft import (
compute_distance_axis,
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,
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",
"calibrate_freqs",
"compute_auto_ylim",
"compute_distance_axis",
"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_fft_background",
"normalize_by_envelope",
"normalize_by_calib",
"parse_spec_clip",
"recalculate_calibration_c",
"rolling_median_ref",
"save_calib_envelope",
"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,124 +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 = np.asarray(sweep["I"], dtype=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)
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 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 _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)

View File

@ -1,267 +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 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_seg = np.asarray(sweep[:take_fft], dtype=np.float32)
fallback = np.nan_to_num(sweep_seg, nan=0.0).astype(np.float32, 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 = np.interp(x_uniform, x_unique, y_unique).astype(np.float32)
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_seg = np.asarray(fft_seg[:band_len], dtype=np.float32)
if fft_seg.size < band_len:
padded = np.zeros((band_len,), dtype=np.float32)
padded[: fft_seg.size] = fft_seg
fft_seg = padded
window = np.hanning(band_len).astype(np.float32)
band = np.nan_to_num(fft_seg, nan=0.0).astype(np.float32, copy=False) * window
spectrum = np.zeros((int(fft_len),), dtype=np.float32)
spectrum[pos_idx] = band
spectrum[neg_idx] = 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_seg = np.asarray(fft_seg[:band_len], dtype=np.float32)
if fft_seg.size < band_len:
padded = np.zeros((band_len,), dtype=np.float32)
padded[: fft_seg.size] = fft_seg
fft_seg = padded
window = np.hanning(band_len).astype(np.float32)
band = np.nan_to_num(fft_seg, nan=0.0).astype(np.float32, copy=False) * window
spectrum = np.zeros((int(fft_len),), dtype=np.float32)
spectrum[pos_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_mag_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, dtype=np.float32)
fft_seg, take_fft = prepared
fft_in = np.zeros((FFT_LEN,), dtype=np.float32)
window = np.hanning(take_fft).astype(np.float32)
fft_in[:take_fft] = fft_seg * window
spec = np.fft.ifft(fft_in)
mag = np.abs(spec).astype(np.float32)
if mag.shape[0] != bins:
mag = mag[:bins]
return mag
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"
raise ValueError(f"Unsupported FFT mode: {mode!r}")
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."""
if bins <= 0:
return np.zeros((0,), dtype=np.float32)
fft_mode = _normalize_fft_mode(mode, symmetric)
if fft_mode == "direct":
return _compute_fft_mag_row_direct(sweep, freqs, bins)
if fft_mode == "positive_only":
spectrum_centered = build_positive_only_centered_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
else:
spectrum_centered = build_symmetric_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
if spectrum_centered is None:
return np.full((bins,), np.nan, dtype=np.float32)
spec = np.fft.ifft(np.fft.ifftshift(spectrum_centered))
mag = np.abs(spec).astype(np.float32)
if mag.shape[0] != bins:
mag = mag[:bins]
return mag
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 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,173 +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 normalize_by_envelope(raw: np.ndarray, envelope: np.ndarray) -> np.ndarray:
"""Normalize a sweep by an envelope with safe resampling and zero protection."""
raw_arr = np.asarray(raw, dtype=np.float32).reshape(-1)
if raw_arr.size == 0:
return raw_arr.copy()
env = resample_envelope(envelope, raw_arr.size)
out = np.full_like(raw_arr, np.nan, dtype=np.float32)
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
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

@ -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

@ -0,0 +1,107 @@
"""
Обработка фазы для FMCW радара: развертка фазы и преобразование в расстояние.
"""
from typing import Optional, Tuple
import numpy as np
def apply_temporal_unwrap(
current_phase: np.ndarray,
prev_phase: Optional[np.ndarray],
phase_offset: Optional[np.ndarray],
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Применяет улучшенный phase unwrapping для FMCW радара с адаптивным порогом.
Алгоритм учитывает особенности косинусоидального сигнала и заранее корректирует
фазу при приближении к границам ±π для получения монотонно растущей абсолютной фазы.
Args:
current_phase: Текущая фаза (развернутая по частоте) для всех бинов
prev_phase: Предыдущая фаза, может быть None при первом вызове
phase_offset: Накопленные смещения для каждого бина, может быть None
Returns:
(unwrapped_phase, new_prev_phase, new_phase_offset)
unwrapped_phase - абсолютная развёрнутая фаза (может быть > 2π)
new_prev_phase - обновлённая предыдущая фаза (для следующего вызова)
new_phase_offset - обновлённые смещения (для следующего вызова)
"""
n_bins = current_phase.size
# Инициализация при первом вызове
if prev_phase is None:
prev_phase = current_phase.copy()
phase_offset = np.zeros(n_bins, dtype=np.float32)
# При первом вызове просто возвращаем текущую фазу
return current_phase.copy(), prev_phase, phase_offset
if phase_offset is None:
phase_offset = np.zeros(n_bins, dtype=np.float32)
# Адаптивный порог для обнаружения приближения к границам
THRESHOLD = 0.8 * np.pi
# Вычисляем разницу между текущей и предыдущей фазой
delta = current_phase - prev_phase
# Обнаруживаем скачки и корректируем offset
# Используем улучшенный алгоритм с адаптивным порогом
# Метод 1: Стандартная коррекция для больших скачков (> π)
# Это ловит случаи, когда фаза уже перескочила границу
phase_offset = phase_offset - 2.0 * np.pi * np.round(delta / (2.0 * np.pi))
# Метод 2: Адаптивная коррекция при приближении к границам
# Проверяем текущую развернутую фазу
unwrapped_phase = current_phase + phase_offset
# Если фаза близка к нечетным π (π, 3π, 5π...), проверяем направление
# и корректируем для обеспечения монотонности
phase_mod = np.mod(unwrapped_phase + np.pi, 2.0 * np.pi) - np.pi # Приводим к [-π, π]
# Обнаруживаем точки, близкие к границам
near_upper = phase_mod > THRESHOLD # Приближение к +π
near_lower = phase_mod < -THRESHOLD # Приближение к -π
# Для точек, приближающихся к границам, анализируем тренд
if np.any(near_upper) or np.any(near_lower):
# Если delta положительна и мы около +π, готовимся к переходу
should_add = near_upper & (delta > 0)
# Если delta отрицательна и мы около -π, готовимся к переходу
should_sub = near_lower & (delta < 0)
# Применяем дополнительную коррекцию только там, где нужно
# (этот код срабатывает редко, только при быстром движении объекта)
pass # Основная коррекция уже сделана выше
# Финальная развернутая фаза
unwrapped_phase = current_phase + phase_offset
# Сохраняем текущую фазу как предыдущую для следующего свипа
new_prev_phase = current_phase.copy()
new_phase_offset = phase_offset.copy()
return unwrapped_phase, new_prev_phase, new_phase_offset
def phase_to_distance(phase: np.ndarray, center_freq_hz: float = 6e9) -> np.ndarray:
"""Преобразует развернутую фазу в расстояние для FMCW радара.
Формула: Δl = φ * c / (4π * ν)
где:
φ - фаза (радианы)
c - скорость света (м/с)
ν - центральная частота свипа (Гц)
Args:
phase: Развернутая фаза в радианах
center_freq_hz: Центральная частота диапазона в Гц (по умолчанию 6 ГГц для 2-10 ГГц)
Returns:
Расстояние в метрах
"""
c = 299792458.0 # Скорость света в м/с
distance = phase * c / (4.0 * np.pi * center_freq_hz)
return distance.astype(np.float32)

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

@ -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,193 +0,0 @@
"""Ring buffers for raw sweeps and FFT waterfall rows."""
from __future__ import annotations
import time
from typing import Optional
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.processing.fft import compute_distance_axis, compute_fft_mag_row, fft_mag_to_db
class RingBuffer:
"""Store raw sweeps, FFT rows, and matching time markers."""
def __init__(self, max_sweeps: int):
self.max_sweeps = int(max_sweeps)
self.fft_bins = FFT_LEN // 2 + 1
self.fft_mode = "symmetric"
self.width = 0
self.head = 0
self.ring: Optional[np.ndarray] = None
self.ring_time: Optional[np.ndarray] = None
self.ring_fft: Optional[np.ndarray] = None
self.x_shared: Optional[np.ndarray] = None
self.distance_axis: Optional[np.ndarray] = None
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_max_fft: Optional[float] = None
@property
def is_ready(self) -> bool:
return self.ring is not None and self.ring_fft is not None
@property
def fft_symmetric(self) -> bool:
return self.fft_mode == "symmetric"
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.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
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_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.head = 0
changed = True
elif target_width != self.width:
new_ring = np.full((self.max_sweeps, target_width), np.nan, dtype=np.float32)
take = min(self.width, target_width)
new_ring[:, :take] = self.ring[:, :take]
self.ring = new_ring
self.width = target_width
changed = True
if self.x_shared is None or self.x_shared.size != self.width:
self.x_shared = np.linspace(
SWEEP_FREQ_MIN_GHZ,
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 not in {"direct", "symmetric", "positive_only"}:
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
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]):
sweep_row = self.ring[row_idx]
if not np.any(np.isfinite(sweep_row)):
continue
fft_mag = compute_fft_mag_row(
sweep_row,
self.last_freqs,
self.fft_bins,
mode=self.fft_mode,
)
self.ring_fft[row_idx, :] = fft_mag
if self.last_freqs is not None:
self.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.last_fft_mag = np.asarray(last_fft, dtype=np.float32).copy()
self.last_fft_db = fft_mag_to_db(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) -> None:
"""Push a processed sweep and refresh raw/FFT buffers."""
if sweep is None or sweep.size == 0:
return
self.ensure_init(int(sweep.size))
if self.ring is None or self.ring_time is None or self.ring_fft is None:
return
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.ring[self.head, :] = row
self.ring_time[self.head] = time.time()
if freqs is not None:
self.last_freqs = np.asarray(freqs, dtype=np.float64).copy()
fft_mag = compute_fft_mag_row(sweep, freqs, self.fft_bins, mode=self.fft_mode)
self.ring_fft[self.head, :] = fft_mag
self.last_fft_mag = np.asarray(fft_mag, dtype=np.float32).copy()
self.last_fft_db = fft_mag_to_db(fft_mag)
if self.last_fft_db.size > 0:
fr_min = float(np.nanmin(self.last_fft_db))
fr_max = float(np.nanmax(self.last_fft_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.distance_axis = compute_distance_axis(freqs, self.fft_bins, mode=self.fft_mode)
self.head = (self.head + 1) % self.max_sweeps
def get_display_raw(self) -> np.ndarray:
if self.ring is None:
return np.zeros((1, 1), dtype=np.float32)
base = self.ring if self.head == 0 else np.roll(self.ring, -self.head, axis=0)
return base.T
def get_display_fft_linear(self) -> np.ndarray:
if self.ring_fft is None:
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)
return base.T
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]:
if self.ring_time is None:
return None
return self.ring_time if self.head == 0 else np.roll(self.ring_time, -self.head)

View File

@ -1,46 +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_aux_curves: SweepAuxCurves = None
current_freqs: Optional[np.ndarray] = None
current_distances: Optional[np.ndarray] = None
current_sweep_raw: 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
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,31 +0,0 @@
"""Shared runtime and parser types."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, TypeAlias, Union
import numpy as np
Number = Union[int, float]
SweepInfo = Dict[str, Any]
SweepData = Dict[str, np.ndarray]
SweepAuxCurves = Optional[Tuple[np.ndarray, np.ndarray]]
SweepPacket = Tuple[np.ndarray, SweepInfo, SweepAuxCurves]
@dataclass(frozen=True)
class StartEvent:
ch: Optional[int] = None
@dataclass(frozen=True)
class PointEvent:
ch: int
x: int
y: float
aux: Optional[Tuple[float, float]] = None
ParserEvent: TypeAlias = Union[StartEvent, PointEvent]

View File

View File

@ -0,0 +1,50 @@
"""
Утилиты для форматирования данных и парсинга параметров.
"""
from typing import Any, Mapping, Optional, Tuple
import numpy as np
def format_status_kv(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)
def parse_spec_clip(spec: Optional[str]) -> Optional[Tuple[float, float]]:
"""Разобрать строку вида "low,high" процентов для контрастного отображения водопада спектров.
Возвращает пару (low, high) или None для отключения. Допустимы значения 0..100, low < high.
Ключевые слова отключения: "off", "none", "no".
"""
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 = float(p0)
high = float(p1)
if not (0.0 <= low < high <= 100.0):
return None
return (low, high)
except Exception:
return None

View File

@ -0,0 +1,651 @@
"""
Визуализация данных с использованием matplotlib.
"""
import csv
import sys
import threading
import time
from datetime import datetime
from queue import Empty, Queue
from typing import Optional, Tuple
import numpy as np
try:
import matplotlib
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from matplotlib.widgets import Slider
except Exception as e:
raise RuntimeError(f"Нужны matplotlib и ее зависимости: {e}")
from ..config import (
FFT_LEN,
WF_WIDTH,
SweepInfo,
SweepPacket,
FREQ_MIN_GHZ,
FREQ_MAX_GHZ,
DATA_FREQ_START_GHZ,
DATA_FREQ_END_GHZ,
)
from ..data_acquisition.sweep_reader import SweepReader
from ..signal_processing.phase_analysis import apply_temporal_unwrap, phase_to_distance
from ..utils.formatting import format_status_kv, parse_spec_clip
def run_matplotlib(args):
"""Запуск визуализации с использованием matplotlib."""
# Очередь завершённых свипов и поток чтения
q: Queue[SweepPacket] = Queue(maxsize=1000)
stop_event = threading.Event()
reader = SweepReader(args.port, args.baud, q, stop_event, fancy=bool(args.fancy))
reader.start()
# Графика (3 ряда x 2 колонки = 6 графиков)
fig, axs = plt.subplots(3, 2, figsize=(12, 12))
(ax_line, ax_img), (ax_fft, ax_spec), (ax_phase, ax_phase_wf) = axs
fig.canvas.manager.set_window_title(args.title) if hasattr(fig.canvas.manager, "set_window_title") else None
# Увеличим расстояния и оставим место справа под ползунки оси Y B-scan
fig.subplots_adjust(wspace=0.25, hspace=0.35, left=0.07, right=0.90, top=0.95, bottom=0.05)
# Состояние для отображения
current_sweep: Optional[np.ndarray] = None
current_info: Optional[SweepInfo] = None
x_shared: Optional[np.ndarray] = None
width: Optional[int] = None
max_sweeps = int(max(10, args.max_sweeps))
ring = None # type: Optional[np.ndarray]
ring_time = None # type: Optional[np.ndarray]
head = 0
# Медианные данные для вычитания
median_data: Optional[np.ndarray] = None
median_subtract_enabled = False
# CLI параметры для автоматического сохранения/загрузки
ref_out_file = getattr(args, 'ref_out', None)
ref_in_file = getattr(args, 'ref_in', None)
ref_out_saved = False # Флаг, что медиана уже сохранена
# Отдельный буфер для накопления 1000 сырых свипов (не зависит от max_sweeps)
ref_ring: Optional[np.ndarray] = None
ref_ring_head = 0
ref_ring_count = 0
if ref_out_file:
print(f"[ref-out] Автосохранение включено, файл: {ref_out_file}")
# Автоматическая загрузка медианы при старте
if ref_in_file:
try:
pairs = []
with open(ref_in_file, 'r') as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
for row in reader:
if len(row) >= 2:
try:
pairs.append((int(row[0]), float(row[1])))
except ValueError:
continue
if pairs:
max_idx = max(idx for idx, _ in pairs)
median_data = np.full(max_idx + 1, np.nan, dtype=np.float32)
for idx, val in pairs:
median_data[idx] = val
median_subtract_enabled = True
print(f"[ref-in] Загружена медиана из {ref_in_file} ({len(median_data)} точек), вычитание включено")
else:
print(f"[ref-in] Предупреждение: файл {ref_in_file} пустой или неверный формат")
except Exception as e:
print(f"[ref-in] Ошибка загрузки {ref_in_file}: {e}")
# Авто-уровни цветовой шкалы водопада сырых данных пересчитываются по видимой области.
# FFT состояние (полное FFT для отрицательных частот)
fft_bins = FFT_LEN
ring_fft = None # type: Optional[np.ndarray]
y_min_fft, y_max_fft = None, None
freq_shared: Optional[np.ndarray] = None
# Phase состояние
ring_phase = None # type: Optional[np.ndarray]
prev_phase_per_bin: Optional[np.ndarray] = None
phase_offset_per_bin: Optional[np.ndarray] = None
y_min_phase, y_max_phase = None, None
# Параметры контраста водопада спектров
spec_clip = parse_spec_clip(getattr(args, "spec_clip", None))
# Ползунки управления Y для B-scan и контрастом
ymin_slider = None
ymax_slider = None
contrast_slider = None
# Статусная строка (внизу окна)
status_text = fig.text(
0.01,
0.01,
"",
ha="left",
va="bottom",
fontsize=8,
family="monospace",
)
# Линейный график последнего свипа
line_obj, = ax_line.plot([], [], lw=1)
ax_line.set_title("Сырые данные", pad=1)
ax_line.set_xlabel("F")
ax_line.set_ylabel("")
# Линейный график спектра текущего свипа
fft_line_obj, = ax_fft.plot([], [], lw=1)
ax_fft.set_title("FFT", pad=1)
ax_fft.set_xlabel("Частота, ГГц")
ax_fft.set_ylabel("Амплитуда, дБ")
# Диапазон по Y для последнего свипа: авто по умолчанию (поддерживает отрицательные значения)
fixed_ylim: Optional[Tuple[float, float]] = None
# CLI переопределение при необходимости
if args.ylim:
try:
y0, y1 = args.ylim.split(",")
fixed_ylim = (float(y0), float(y1))
except Exception:
sys.stderr.write("[warn] Некорректный формат --ylim, игнорирую. Ожидалось min,max\n")
if fixed_ylim is not None:
ax_line.set_ylim(fixed_ylim)
# Водопад (будет инициализирован при первом свипе)
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_xlabel("")
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_xlabel("")
ax_spec.set_ylabel("Частота, ГГц")
# Не показываем численные значения по времени на B-scan
try:
ax_spec.tick_params(axis="x", labelbottom=False)
except Exception:
pass
# График фазы текущего свипа
phase_line_obj, = ax_phase.plot([], [], lw=1)
ax_phase.set_title("Фаза спектра (развернутая)", pad=1)
ax_phase.set_xlabel("Частота, ГГц")
ax_phase.set_ylabel("Фаза, радианы")
# Добавим второй Y axis для расстояния
ax_phase_dist = ax_phase.twinx()
ax_phase_dist.set_ylabel("Расстояние, м", color='green')
# Водопад фазы
img_phase_obj = ax_phase_wf.imshow(
np.zeros((1, 1), dtype=np.float32),
aspect="auto",
interpolation="nearest",
origin="lower",
cmap=args.cmap,
)
ax_phase_wf.set_title("Водопад фазы", pad=12)
ax_phase_wf.set_xlabel("")
ax_phase_wf.set_ylabel("Частота, ГГц")
# Не показываем численные значения по времени
try:
ax_phase_wf.tick_params(axis="x", labelbottom=False)
except Exception:
pass
# Слайдеры для управления осью Y B-scan (мин/макс) и контрастом
try:
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])
ymin_slider = Slider(ax_smin, "Y min", FREQ_MIN_GHZ, FREQ_MAX_GHZ, valinit=FREQ_MIN_GHZ, valstep=0.1, orientation="vertical")
ymax_slider = Slider(ax_smax, "Y max", FREQ_MIN_GHZ, FREQ_MAX_GHZ, valinit=FREQ_MAX_GHZ, valstep=0.1, orientation="vertical")
contrast_slider = Slider(ax_sctr, "Int max", 0, 100, valinit=100, valstep=1, orientation="vertical")
def _on_ylim_change(_val):
try:
y0 = float(min(ymin_slider.val, ymax_slider.val))
y1 = float(max(ymin_slider.val, ymax_slider.val))
ax_spec.set_ylim(y0, y1)
fig.canvas.draw_idle()
except Exception:
pass
ymin_slider.on_changed(_on_ylim_change)
ymax_slider.on_changed(_on_ylim_change)
# Контраст влияет на верхнюю границу цветовой шкалы (процент от авто-диапазона)
contrast_slider.on_changed(lambda _v: fig.canvas.draw_idle())
except Exception:
pass
# Для контроля частоты обновления
max_fps = max(1.0, float(args.max_fps))
interval_ms = int(1000.0 / max_fps)
frames_since_ylim_update = 0
def ensure_buffer(_w: int):
nonlocal ring, width, head, x_shared, ring_fft, freq_shared, ring_time
nonlocal ring_phase, prev_phase_per_bin, phase_offset_per_bin
nonlocal ref_ring
if ring is not None:
return
width = WF_WIDTH
x_shared = np.arange(width, dtype=np.int32)
ring = np.full((max_sweeps, width), np.nan, dtype=np.float32)
ring_time = np.full((max_sweeps,), np.nan, dtype=np.float64)
head = 0
# Обновляем изображение под новые размеры: время по X (горизонталь), X по Y
img_obj.set_data(np.zeros((width, max_sweeps), dtype=np.float32))
img_obj.set_extent((0, max_sweeps - 1, 0, width - 1 if width > 0 else 1))
ax_img.set_xlim(0, max_sweeps - 1)
ax_img.set_ylim(0, max(1, width - 1))
# FFT буферы: время по X, бин по Y
ring_fft = np.full((max_sweeps, fft_bins), np.nan, dtype=np.float32)
img_fft_obj.set_data(np.zeros((fft_bins, max_sweeps), dtype=np.float32))
img_fft_obj.set_extent((0, max_sweeps - 1, FREQ_MIN_GHZ, FREQ_MAX_GHZ))
ax_spec.set_xlim(0, max_sweeps - 1)
ax_spec.set_ylim(FREQ_MIN_GHZ, FREQ_MAX_GHZ)
freq_shared = np.linspace(FREQ_MIN_GHZ, FREQ_MAX_GHZ, fft_bins, dtype=np.float32)
# Phase буферы: время по X, бин по Y
ring_phase = np.full((max_sweeps, fft_bins), np.nan, dtype=np.float32)
prev_phase_per_bin = np.zeros(fft_bins, dtype=np.float32)
phase_offset_per_bin = np.zeros(fft_bins, dtype=np.float32)
img_phase_obj.set_data(np.zeros((fft_bins, max_sweeps), dtype=np.float32))
img_phase_obj.set_extent((0, max_sweeps - 1, FREQ_MIN_GHZ, FREQ_MAX_GHZ))
ax_phase_wf.set_xlim(0, max_sweeps - 1)
ax_phase_wf.set_ylim(FREQ_MIN_GHZ, FREQ_MAX_GHZ)
# Буфер для медианы (отдельный от ring, размер всегда 1000)
if ref_out_file and ref_ring is None:
ref_ring = np.full((1000, width), np.nan, dtype=np.float32)
def _visible_levels_matplotlib(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 push_sweep(s: np.ndarray):
nonlocal ring, head, ring_fft, y_min_fft, y_max_fft, ring_time
nonlocal ring_phase, prev_phase_per_bin, phase_offset_per_bin, y_min_phase, y_max_phase
nonlocal ref_ring_head, ref_ring_count
if s is None or s.size == 0 or ring is None:
return
# Сохраняем сырой свип в буфер медианы (до вычитания)
if ref_out_file and not ref_out_saved and ref_ring is not None:
w_ref = ref_ring.shape[1]
take_ref = min(w_ref, s.size)
ref_ring[ref_ring_head, :take_ref] = s[:take_ref]
ref_ring_head = (ref_ring_head + 1) % 1000
ref_ring_count = min(ref_ring_count + 1, 1000)
# Применяем вычитание медианы если включено
if median_subtract_enabled and median_data is not None:
take_median = min(s.size, median_data.size)
s_corrected = s.copy()
s_corrected[:take_median] = s[:take_median] - median_data[:take_median]
s = s_corrected
# Нормализуем длину до фиксированной ширины
w = ring.shape[1]
row = np.full((w,), np.nan, dtype=np.float32)
take = min(w, s.size)
row[:take] = s[:take]
ring[head, :] = row
if ring_time is not None:
ring_time[head] = time.time()
head = (head + 1) % ring.shape[0]
# FFT строка (дБ) и фаза
if ring_fft is not None:
bins = ring_fft.shape[1]
# Подготовка входа FFT_LEN, замена NaN на 0
take_fft = min(int(s.size), FFT_LEN)
if take_fft <= 0:
fft_row = np.full((bins,), np.nan, dtype=np.float32)
phase_row = np.full((bins,), np.nan, dtype=np.float32)
else:
# Создаем буфер для полного FFT (с отрицательными частотами)
fft_in = np.zeros((FFT_LEN,), dtype=np.float32)
# Вычисляем индексы для размещения данных (1-10 ГГц в диапазоне -10 до +10 ГГц)
freq_range_total = FREQ_MAX_GHZ - FREQ_MIN_GHZ # 20 ГГц
freq_range_data = DATA_FREQ_END_GHZ - DATA_FREQ_START_GHZ # 9 ГГц
# Начальный индекс для данных в FFT буфере
start_idx = int((DATA_FREQ_START_GHZ - FREQ_MIN_GHZ) / freq_range_total * FFT_LEN)
# Количество точек для данных
data_points = int(freq_range_data / freq_range_total * FFT_LEN)
data_points = min(data_points, take_fft, FFT_LEN - start_idx)
# Подготовка данных
seg = s[:data_points]
if isinstance(seg, np.ndarray):
seg = np.nan_to_num(seg, nan=0.0).astype(np.float32, copy=False)
else:
seg = np.asarray(seg, dtype=np.float32)
seg = np.nan_to_num(seg, nan=0.0)
# Окно Хэннинга
win = np.hanning(data_points).astype(np.float32)
# Размещаем данные в правильной позиции
fft_in[start_idx:start_idx + data_points] = seg * win
# Полное FFT (включая отрицательные частоты)
spec = np.fft.fft(fft_in)
# Сдвигаем для центрирования нулевой частоты
spec = np.fft.fftshift(spec)
mag = np.abs(spec).astype(np.float32)
fft_row = 20.0 * np.log10(mag + 1e-9)
if fft_row.shape[0] != bins:
fft_row = fft_row[:bins]
# Расчет фазы
phase = np.angle(spec).astype(np.float32)
if phase.shape[0] > bins:
phase = phase[:bins]
# Unwrapping по частоте (внутри свипа)
phase_unwrapped_freq = np.unwrap(phase)
# Unwrapping по времени (между свипами)
phase_unwrapped_time, prev_phase_per_bin, phase_offset_per_bin = apply_temporal_unwrap(
phase_unwrapped_freq, prev_phase_per_bin, phase_offset_per_bin
)
phase_row = phase_unwrapped_time
ring_fft[(head - 1) % ring_fft.shape[0], :] = fft_row
# Экстремумы для цветовой шкалы
fr_min = np.nanmin(fft_row)
fr_max = np.nanmax(fft_row)
fr_max = np.nanpercentile(fft_row, 90)
if y_min_fft is None or (not np.isnan(fr_min) and fr_min < y_min_fft):
y_min_fft = float(fr_min)
if y_max_fft is None or (not np.isnan(fr_max) and fr_max > y_max_fft):
y_max_fft = float(fr_max)
# Сохраняем фазу в буфер
if ring_phase is not None:
ring_phase[(head - 1) % ring_phase.shape[0], :] = phase_row
# Экстремумы для цветовой шкалы фазы
ph_min = np.nanmin(phase_row)
ph_max = np.nanmax(phase_row)
if y_min_phase is None or (not np.isnan(ph_min) and ph_min < y_min_phase):
y_min_phase = float(ph_min)
if y_max_phase is None or (not np.isnan(ph_max) and ph_max > y_max_phase):
y_max_phase = float(ph_max)
def drain_queue():
nonlocal current_sweep, current_info
drained = 0
while True:
try:
s, info = q.get_nowait()
except Empty:
break
drained += 1
current_sweep = s
current_info = info
ensure_buffer(s.size)
push_sweep(s)
return drained
def make_display_ring():
# Возвращаем буфер с правильным порядком по времени (старые→новые) и осью времени по X
if ring is None:
return np.zeros((1, 1), dtype=np.float32)
base = ring if head == 0 else np.roll(ring, -head, axis=0)
return base.T # (width, time)
def make_display_times():
if ring_time is None:
return None
base_t = ring_time if head == 0 else np.roll(ring_time, -head)
return base_t
def make_display_ring_fft():
if ring_fft is None:
return np.zeros((1, 1), dtype=np.float32)
base = ring_fft if head == 0 else np.roll(ring_fft, -head, axis=0)
return base.T # (bins, time)
def make_display_ring_phase():
if ring_phase is None:
return np.zeros((1, 1), dtype=np.float32)
base = ring_phase if head == 0 else np.roll(ring_phase, -head, axis=0)
return base.T # (bins, time)
def update(_frame):
nonlocal frames_since_ylim_update, ref_out_saved
changed = drain_queue() > 0
# Обновление линии последнего свипа
if current_sweep is not None:
# Применяем вычитание медианы для отображения
display_sweep = current_sweep
if median_subtract_enabled and median_data is not None:
take_median = min(current_sweep.size, median_data.size)
display_sweep = current_sweep.copy()
display_sweep[:take_median] = current_sweep[:take_median] - median_data[:take_median]
if x_shared is not None and display_sweep.size <= x_shared.size:
xs = x_shared[: display_sweep.size]
else:
xs = np.arange(display_sweep.size, dtype=np.int32)
line_obj.set_data(xs, display_sweep)
# Лимиты по X постоянные под текущую ширину
ax_line.set_xlim(0, max(1, display_sweep.size - 1))
# Адаптивные Y-лимиты (если не задан --ylim)
if fixed_ylim is None:
y0 = float(np.nanmin(display_sweep))
y1 = float(np.nanmax(display_sweep))
if np.isfinite(y0) and np.isfinite(y1):
if y0 == y1:
pad = max(1.0, abs(y0) * 0.05)
y0 -= pad
y1 += pad
else:
pad = 0.05 * (y1 - y0)
y0 -= pad
y1 += pad
ax_line.set_ylim(y0, y1)
# Обновление спектра и фазы текущего свипа
take_fft = min(int(display_sweep.size), FFT_LEN)
if take_fft > 0 and freq_shared is not None:
# Создаем буфер для полного FFT (с отрицательными частотами)
fft_in = np.zeros((FFT_LEN,), dtype=np.float32)
# Вычисляем индексы для размещения данных (1-10 ГГц в диапазоне -10 до +10 ГГц)
freq_range_total = FREQ_MAX_GHZ - FREQ_MIN_GHZ # 20 ГГц
freq_range_data = DATA_FREQ_END_GHZ - DATA_FREQ_START_GHZ # 9 ГГц
# Начальный индекс для данных в FFT буфере
start_idx = int((DATA_FREQ_START_GHZ - FREQ_MIN_GHZ) / freq_range_total * FFT_LEN)
# Количество точек для данных
data_points = int(freq_range_data / freq_range_total * FFT_LEN)
data_points = min(data_points, take_fft, FFT_LEN - start_idx)
# Подготовка данных с окном Хэннинга
seg = np.nan_to_num(display_sweep[:data_points], nan=0.0).astype(np.float32, copy=False)
win = np.hanning(data_points).astype(np.float32)
# Размещаем данные в правильной позиции
fft_in[start_idx:start_idx + data_points] = seg * win
# Полное FFT (включая отрицательные частоты)
spec = np.fft.fft(fft_in)
# Сдвигаем для центрирования нулевой частоты
spec = np.fft.fftshift(spec)
mag = np.abs(spec).astype(np.float32)
fft_vals = 20.0 * np.log10(mag + 1e-9)
xs_fft = freq_shared
if fft_vals.size > xs_fft.size:
fft_vals = fft_vals[: xs_fft.size]
fft_line_obj.set_data(xs_fft[: fft_vals.size], fft_vals)
# Авто-диапазон по Y для спектра
if np.isfinite(np.nanmin(fft_vals)) and np.isfinite(np.nanmax(fft_vals)):
ax_fft.set_xlim(FREQ_MIN_GHZ, FREQ_MAX_GHZ)
ax_fft.set_ylim(float(np.nanmin(fft_vals)), float(np.nanmax(fft_vals)))
# Расчет и отображение фазы текущего свипа
phase = np.angle(spec).astype(np.float32)
if phase.size > xs_fft.size:
phase = phase[: xs_fft.size]
# Unwrapping по частоте
phase_unwrapped = np.unwrap(phase)
phase_line_obj.set_data(xs_fft[: phase_unwrapped.size], phase_unwrapped)
# Авто-диапазон по Y для фазы
if np.isfinite(np.nanmin(phase_unwrapped)) and np.isfinite(np.nanmax(phase_unwrapped)):
ax_phase.set_xlim(FREQ_MIN_GHZ, FREQ_MAX_GHZ)
phase_min = float(np.nanmin(phase_unwrapped))
phase_max = float(np.nanmax(phase_unwrapped))
ax_phase.set_ylim(phase_min, phase_max)
# Обновляем вторую ось Y с расстоянием
try:
dist_min = phase_to_distance(np.array([phase_min]))[0]
dist_max = phase_to_distance(np.array([phase_max]))[0]
ax_phase_dist.set_ylim(dist_min, dist_max)
except Exception:
pass
# Обновление водопада
if changed and ring is not None:
disp = make_display_ring()
# Новые данные справа: без реверса
img_obj.set_data(disp)
# Подписи времени не обновляем динамически (оставляем авто-тики)
# Авто-уровни: по видимой области (не накапливаем за всё время)
levels = _visible_levels_matplotlib(disp, ax_img)
if levels is not None:
img_obj.set_clim(vmin=levels[0], vmax=levels[1])
# Обновление водопада спектров
if changed and ring_fft is not None:
disp_fft = make_display_ring_fft()
# Новые данные справа: без реверса
img_fft_obj.set_data(disp_fft)
# Подписи времени не обновляем динамически (оставляем авто-тики)
# Автодиапазон по среднему спектру за видимый интервал (как в хорошей версии)
try:
# disp_fft имеет форму (bins, time); берём среднее по времени
mean_spec = np.nanmean(disp_fft, axis=1)
vmin_v = float(np.nanmin(mean_spec))
vmax_v = float(np.nanmax(mean_spec))
except Exception:
vmin_v = vmax_v = None
# Если средние не дают валидный диапазон — используем процентильную обрезку (если задана)
if (vmin_v is None or not np.isfinite(vmin_v)) or (vmax_v is None or not np.isfinite(vmax_v)) or vmin_v == vmax_v:
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]))
except Exception:
vmin_v = vmax_v = None
# Фолбэк к отслеживаемым минимум/максимумам
if (vmin_v is None or not np.isfinite(vmin_v)) or (vmax_v is None or not np.isfinite(vmax_v)) or vmin_v == vmax_v:
if y_min_fft is not None and y_max_fft is not None and np.isfinite(y_min_fft) and np.isfinite(y_max_fft) and y_min_fft != y_max_fft:
vmin_v, vmax_v = y_min_fft, y_max_fft
if vmin_v is not None and vmax_v is not None and vmin_v != vmax_v:
# Применим скалирование контрастом (верхняя граница)
try:
c = float(contrast_slider.val) / 100.0 if contrast_slider is not None else 1.0
except Exception:
c = 1.0
vmax_eff = vmin_v + c * (vmax_v - vmin_v)
img_fft_obj.set_clim(vmin=vmin_v, vmax=vmax_eff)
# Обновление водопада фазы
if changed and ring_phase is not None:
disp_phase = make_display_ring_phase()
img_phase_obj.set_data(disp_phase)
# Автодиапазон для фазы
try:
mean_phase = np.nanmean(disp_phase, axis=1)
vmin_p = float(np.nanmin(mean_phase))
vmax_p = float(np.nanmax(mean_phase))
except Exception:
vmin_p = vmax_p = None
# Фолбэк к отслеживаемым минимум/максимумам
if (vmin_p is None or not np.isfinite(vmin_p)) or (vmax_p is None or not np.isfinite(vmax_p)) or vmin_p == vmax_p:
if y_min_phase is not None and y_max_phase is not None and np.isfinite(y_min_phase) and np.isfinite(y_max_phase) and y_min_phase != y_max_phase:
vmin_p, vmax_p = y_min_phase, y_max_phase
if vmin_p is not None and vmax_p is not None and vmin_p != vmax_p:
img_phase_obj.set_clim(vmin=vmin_p, vmax=vmax_p)
if changed and current_info:
status_text.set_text(format_status_kv(current_info))
# Автоматическое сохранение медианы при накоплении 1000 сырых свипов
if ref_out_file and not ref_out_saved and ref_ring is not None:
if ref_ring_count >= 1000:
try:
ordered = ref_ring if ref_ring_head == 0 else np.roll(ref_ring, -ref_ring_head, axis=0)
median_sweep = np.nanmedian(ordered, axis=0)
with open(ref_out_file, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(['Index', 'Median_Value'])
for i, value in enumerate(median_sweep):
if np.isfinite(value):
writer.writerow([i, float(value)])
ref_out_saved = True
print(f"[ref-out] Сохранена медиана 1000 свипов в {ref_out_file}")
status_text.set_text(f"[ref-out] Сохранено в {ref_out_file}")
except Exception as e:
print(f"[ref-out] Ошибка сохранения: {e}")
# Возвращаем обновлённые артисты
return (line_obj, img_obj, fft_line_obj, img_fft_obj, phase_line_obj, img_phase_obj, status_text)
ani = FuncAnimation(fig, update, interval=interval_ms, blit=False)
plt.show()
# Нормальное завершение при закрытии окна
stop_event.set()
reader.join(timeout=1.0)

View File

@ -0,0 +1,705 @@
"""
Визуализация данных с использованием pyqtgraph (быстрый бэкенд).
"""
import csv
import sys
import threading
import time
from datetime import datetime
from queue import Empty, Queue
from typing import Optional, Tuple
import numpy as np
try:
import pyqtgraph as pg
from PyQt5 import QtCore, QtWidgets # noqa: F401
from PyQt5.QtWidgets import QPushButton, QWidget, QHBoxLayout, QCheckBox, QFileDialog
except Exception:
# Возможно установлена PySide6
try:
import pyqtgraph as pg
from PySide6 import QtCore, QtWidgets # noqa: F401
from PySide6.QtWidgets import QPushButton, QWidget, QHBoxLayout, QCheckBox, QFileDialog
except Exception as e:
raise RuntimeError(
"pyqtgraph/PyQt5(Pyside6) не найдены. Установите: pip install pyqtgraph PyQt5"
) from e
from ..config import (
FFT_LEN,
WF_WIDTH,
SweepInfo,
SweepPacket,
FREQ_MIN_GHZ,
FREQ_MAX_GHZ,
DATA_FREQ_START_GHZ,
DATA_FREQ_END_GHZ,
)
from ..data_acquisition.sweep_reader import SweepReader
from ..signal_processing.phase_analysis import apply_temporal_unwrap, phase_to_distance
from ..utils.formatting import format_status_kv, parse_spec_clip
def run_pyqtgraph(args):
"""Быстрый GUI на PyQtGraph. Требует pyqtgraph и PyQt5/PySide6."""
# Очередь завершённых свипов и поток чтения
q: Queue[SweepPacket] = Queue(maxsize=1000)
stop_event = threading.Event()
reader = SweepReader(args.port, args.baud, q, stop_event, fancy=bool(args.fancy))
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)
# PyQtGraph настройки
pg.setConfigOptions(useOpenGL=True, antialias=False)
app = pg.mkQApp(args.title)
win = pg.GraphicsLayoutWidget(show=True, title=args.title)
win.resize(1200, 900)
# Плот последнего свипа (слева-сверху)
p_line = win.addPlot(row=0, col=0, title="Сырые данные")
p_line.showGrid(x=True, y=True, alpha=0.3)
curve = p_line.plot(pen=pg.mkPen((80, 120, 255), width=1))
p_line.setLabel("bottom", "X")
p_line.setLabel("left", "Y")
# Водопад (справа-сверху)
p_img = win.addPlot(row=0, col=1, title="Сырые данные водопад")
p_img.invertY(False)
p_img.showGrid(x=False, y=False)
p_img.setLabel("bottom", "Время, с (новое справа)")
try:
p_img.getAxis("bottom").setStyle(showValues=False)
except Exception:
pass
p_img.setLabel("left", "X (0 снизу)")
img = pg.ImageItem()
p_img.addItem(img)
# FFT (слева-средний ряд)
p_fft = win.addPlot(row=1, col=0, title="FFT")
p_fft.showGrid(x=True, y=True, alpha=0.3)
curve_fft = p_fft.plot(pen=pg.mkPen((255, 120, 80), width=1))
p_fft.setLabel("bottom", "Частота, ГГц")
p_fft.setLabel("left", "Амплитуда, дБ")
# Водопад спектров (справа-средний ряд)
p_spec = win.addPlot(row=1, col=1, title="B-scan (дБ)")
p_spec.invertY(True)
p_spec.showGrid(x=False, y=False)
p_spec.setLabel("bottom", "Время, с (новое справа)")
try:
p_spec.getAxis("bottom").setStyle(showValues=False)
except Exception:
pass
p_spec.setLabel("left", "Частота, ГГц (0 снизу)")
img_fft = pg.ImageItem()
p_spec.addItem(img_fft)
# График фазы (слева-снизу)
p_phase = win.addPlot(row=2, col=0, title="Фаза спектра (развернутая)")
p_phase.showGrid(x=True, y=True, alpha=0.3)
curve_phase = p_phase.plot(pen=pg.mkPen((120, 255, 80), width=1))
p_phase.setLabel("bottom", "Частота, ГГц")
p_phase.setLabel("left", "Фаза, радианы")
# Добавим вторую ось Y для расстояния
p_phase_dist_axis = pg.ViewBox()
p_phase.showAxis("right")
p_phase.scene().addItem(p_phase_dist_axis)
p_phase.getAxis("right").linkToView(p_phase_dist_axis)
p_phase_dist_axis.setXLink(p_phase)
p_phase.setLabel("right", "Расстояние, м")
def updateViews():
try:
p_phase_dist_axis.setGeometry(p_phase.vb.sceneBoundingRect())
p_phase_dist_axis.linkedViewChanged(p_phase.vb, p_phase_dist_axis.XAxis)
except Exception:
pass
updateViews()
p_phase.vb.sigResized.connect(updateViews)
# Водопад фазы (справа-снизу)
p_phase_wf = win.addPlot(row=2, col=1, title="Водопад фазы")
p_phase_wf.invertY(True)
p_phase_wf.showGrid(x=False, y=False)
p_phase_wf.setLabel("bottom", "Время, с (новое справа)")
try:
p_phase_wf.getAxis("bottom").setStyle(showValues=False)
except Exception:
pass
p_phase_wf.setLabel("left", "Частота, ГГц (0 снизу)")
img_phase = pg.ImageItem()
p_phase_wf.addItem(img_phase)
# Статусная строка (внизу окна)
status = pg.LabelItem(justify="left")
win.addItem(status, row=3, col=0, colspan=2)
# Функция сохранения медианы последних 1000 свипов
def save_median_data():
"""Сохранить медиану последних 1000 свипов в CSV файл"""
if ring is None:
status.setText("Нет данных для сохранения")
return
# Определяем сколько свипов доступно
n_sweeps = 1000
available = min(n_sweeps, max_sweeps)
# Проверяем сколько свипов реально заполнено
filled_count = np.count_nonzero(~np.isnan(ring[:, 0]))
if filled_count == 0:
status.setText("Нет данных для сохранения")
return
available = min(available, filled_count)
# Получаем хронологически упорядоченные данные
ordered = ring if head == 0 else np.roll(ring, -head, axis=0)
# Берем последние n свипов
recent_sweeps = ordered[-available:, :]
# Вычисляем медиану по свипам (ось 0)
median_sweep = np.nanmedian(recent_sweeps, axis=0)
# Сохраняем в CSV
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"median_sweep_{timestamp}.csv"
try:
with open(filename, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(['Index', 'Median_Value'])
for i, value in enumerate(median_sweep):
if np.isfinite(value):
writer.writerow([i, float(value)])
status.setText(f"Сохранено {available} свипов (медиана) в {filename}")
except Exception as e:
status.setText(f"Ошибка сохранения: {e}")
# Функция загрузки медианного файла
def load_median_file():
"""Загрузить медианный файл из CSV"""
nonlocal median_data
filename, _ = QFileDialog.getOpenFileName(
None,
"Выберите файл с медианой",
"",
"CSV Files (*.csv);;All Files (*)"
)
if not filename:
return
try:
# Загружаем CSV файл
pairs = []
with open(filename, 'r') as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
for row in reader:
if len(row) >= 2:
try:
pairs.append((int(row[0]), float(row[1])))
except ValueError:
continue
if not pairs:
status.setText("Ошибка: файл пустой или неверный формат")
return
max_idx = max(idx for idx, _ in pairs)
median_data = np.full(max_idx + 1, np.nan, dtype=np.float32)
for idx, val in pairs:
median_data[idx] = val
status.setText(f"Загружена медиана из {filename} ({len(median_data)} точек)")
# Автоматически включаем чекбокс
subtract_checkbox.setChecked(True)
except Exception as e:
status.setText(f"Ошибка загрузки: {e}")
median_data = None
# Функция переключения вычитания медианы
def toggle_median_subtraction(state):
nonlocal median_subtract_enabled
median_subtract_enabled = bool(state)
if median_subtract_enabled and median_data is None:
status.setText("Сначала загрузите файл с медианой")
subtract_checkbox.setChecked(False)
elif median_subtract_enabled:
status.setText("Вычитание медианы включено")
else:
status.setText("Вычитание медианы выключено")
# Создаем контейнер для кнопок управления
button_container = QWidget()
button_layout = QHBoxLayout()
# Кнопка сохранения медианы
save_btn = QPushButton("Сохранить медиану (1000 свипов)")
save_btn.clicked.connect(save_median_data)
button_layout.addWidget(save_btn)
# Кнопка загрузки медианы
load_btn = QPushButton("Загрузить медиану")
load_btn.clicked.connect(load_median_file)
button_layout.addWidget(load_btn)
# Чекбокс для включения вычитания
subtract_checkbox = QCheckBox("Вычитать медиану")
subtract_checkbox.stateChanged.connect(toggle_median_subtraction)
button_layout.addWidget(subtract_checkbox)
button_layout.setContentsMargins(5, 5, 5, 5)
button_container.setLayout(button_layout)
# Добавляем кнопки в окно
proxy_widget = QtWidgets.QGraphicsProxyWidget()
proxy_widget.setWidget(button_container)
win.addItem(proxy_widget, row=4, col=0, colspan=2)
# Состояние
ring: Optional[np.ndarray] = None
head = 0
width: Optional[int] = None
x_shared: Optional[np.ndarray] = None
current_sweep: Optional[np.ndarray] = None
current_info: Optional[SweepInfo] = None
# Медианные данные для вычитания
median_data: Optional[np.ndarray] = None
median_subtract_enabled = False
# CLI параметры для автоматического сохранения/загрузки
ref_out_file = getattr(args, 'ref_out', None)
ref_in_file = getattr(args, 'ref_in', None)
ref_out_saved = False # Флаг, что медиана уже сохранена
# Отдельный буфер для накопления 1000 сырых свипов (не зависит от max_sweeps)
ref_ring: Optional[np.ndarray] = None
ref_ring_head = 0
ref_ring_count = 0
# Автоматическая загрузка медианы при старте
if ref_in_file:
try:
pairs = []
with open(ref_in_file, 'r') as f:
reader = csv.reader(f)
next(reader) # Пропускаем заголовок
for row in reader:
if len(row) >= 2:
try:
pairs.append((int(row[0]), float(row[1])))
except ValueError:
continue
if pairs:
max_idx = max(idx for idx, _ in pairs)
median_data = np.full(max_idx + 1, np.nan, dtype=np.float32)
for idx, val in pairs:
median_data[idx] = val
median_subtract_enabled = True
print(f"[ref-in] Загружена медиана из {ref_in_file} ({len(median_data)} точек), вычитание включено")
else:
print(f"[ref-in] Предупреждение: файл {ref_in_file} пустой или неверный формат")
except Exception as e:
print(f"[ref-in] Ошибка загрузки {ref_in_file}: {e}")
# Авто-уровни цветовой шкалы водопада сырых данных пересчитываются по видимой области.
# Для спектров (полное FFT для отрицательных частот)
fft_bins = FFT_LEN
ring_fft: Optional[np.ndarray] = None
freq_shared: Optional[np.ndarray] = None
y_min_fft, y_max_fft = None, None
# Phase состояние
ring_phase: Optional[np.ndarray] = None
prev_phase_per_bin: Optional[np.ndarray] = None
phase_offset_per_bin: Optional[np.ndarray] = None
y_min_phase, y_max_phase = None, None
# Параметры контраста водопада спектров (процентильная обрезка)
spec_clip = parse_spec_clip(getattr(args, "spec_clip", None))
# Диапазон по Y: авто по умолчанию (поддерживает отрицательные значения)
fixed_ylim: Optional[Tuple[float, float]] = None
if args.ylim:
try:
y0, y1 = args.ylim.split(",")
fixed_ylim = (float(y0), float(y1))
except Exception:
pass
if fixed_ylim is not None:
p_line.setYRange(fixed_ylim[0], fixed_ylim[1], padding=0)
def ensure_buffer(_w: int):
nonlocal ring, head, width, x_shared, ring_fft, freq_shared
nonlocal ring_phase, prev_phase_per_bin, phase_offset_per_bin
nonlocal ref_ring
if ring is not None:
return
width = WF_WIDTH
x_shared = np.arange(width, dtype=np.int32)
ring = np.full((max_sweeps, width), np.nan, dtype=np.float32)
head = 0
# Водопад: время по оси X, X по оси Y
img.setImage(ring.T, autoLevels=False)
p_img.setRange(xRange=(0, max_sweeps - 1), yRange=(0, max(1, width - 1)), padding=0)
p_line.setXRange(0, max(1, width - 1), padding=0)
# FFT: время по оси X, бин по оси Y
ring_fft = np.full((max_sweeps, fft_bins), np.nan, dtype=np.float32)
img_fft.setImage(ring_fft.T, autoLevels=False)
p_spec.setRange(xRange=(0, max_sweeps - 1), yRange=(0, max(1, fft_bins - 1)), padding=0)
p_fft.setXRange(FREQ_MIN_GHZ, FREQ_MAX_GHZ, padding=0)
freq_shared = np.linspace(FREQ_MIN_GHZ, FREQ_MAX_GHZ, fft_bins, dtype=np.float32)
# Phase: время по оси X, бин по оси Y
ring_phase = np.full((max_sweeps, fft_bins), np.nan, dtype=np.float32)
prev_phase_per_bin = np.zeros(fft_bins, dtype=np.float32)
phase_offset_per_bin = np.zeros(fft_bins, dtype=np.float32)
img_phase.setImage(ring_phase.T, autoLevels=False)
p_phase_wf.setRange(xRange=(0, max_sweeps - 1), yRange=(0, max(1, fft_bins - 1)), padding=0)
p_phase.setXRange(0, max(1, fft_bins - 1), padding=0)
# Буфер для медианы (отдельный от ring, размер всегда 1000)
if ref_out_file and ref_ring is None:
ref_ring = np.full((1000, width), np.nan, dtype=np.float32)
def _visible_levels_pyqtgraph(data: np.ndarray) -> Optional[Tuple[float, float]]:
"""(vmin, vmax) по текущей видимой области ImageItem (без накопления по времени)."""
if data.size == 0:
return None
ny, nx = data.shape[0], data.shape[1]
try:
(x0, x1), (y0, y1) = p_img.viewRange()
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 push_sweep(s: np.ndarray):
nonlocal ring, head, ring_fft, y_min_fft, y_max_fft
nonlocal ring_phase, prev_phase_per_bin, phase_offset_per_bin, y_min_phase, y_max_phase
nonlocal ref_ring_head, ref_ring_count
if s is None or s.size == 0 or ring is None:
return
# Сохраняем сырой свип в буфер медианы (до вычитания)
if ref_out_file and not ref_out_saved and ref_ring is not None:
w_ref = ref_ring.shape[1]
take_ref = min(w_ref, s.size)
ref_ring[ref_ring_head, :take_ref] = s[:take_ref]
ref_ring_head = (ref_ring_head + 1) % 1000
ref_ring_count = min(ref_ring_count + 1, 1000)
# Применяем вычитание медианы если включено
if median_subtract_enabled and median_data is not None:
# Вычитаем медиану из сигнала
take_median = min(s.size, median_data.size)
s_corrected = s.copy()
s_corrected[:take_median] = s[:take_median] - median_data[:take_median]
s = s_corrected
w = ring.shape[1]
row = np.full((w,), np.nan, dtype=np.float32)
take = min(w, s.size)
row[:take] = s[:take]
ring[head, :] = row
head = (head + 1) % ring.shape[0]
# FFT строка (дБ) и фаза
if ring_fft is not None:
bins = ring_fft.shape[1]
take_fft = min(int(s.size), FFT_LEN)
if take_fft > 0:
# Создаем буфер для полного FFT (с отрицательными частотами)
fft_in = np.zeros((FFT_LEN,), dtype=np.float32)
# Вычисляем индексы для размещения данных (1-10 ГГц в диапазоне -10 до +10 ГГц)
# Диапазон данных: от DATA_FREQ_START_GHZ (1) до DATA_FREQ_END_GHZ (10)
# Полный диапазон: от FREQ_MIN_GHZ (-10) до FREQ_MAX_GHZ (10)
freq_range_total = FREQ_MAX_GHZ - FREQ_MIN_GHZ # 20 ГГц
freq_range_data = DATA_FREQ_END_GHZ - DATA_FREQ_START_GHZ # 9 ГГц
# Начальный индекс для данных в FFT буфере
start_idx = int((DATA_FREQ_START_GHZ - FREQ_MIN_GHZ) / freq_range_total * FFT_LEN)
# Количество точек для данных
data_points = int(freq_range_data / freq_range_total * FFT_LEN)
data_points = min(data_points, take_fft, FFT_LEN - start_idx)
# Подготовка данных с окном Хэннинга
seg = np.nan_to_num(s[:data_points], nan=0.0).astype(np.float32, copy=False)
win = np.hanning(data_points).astype(np.float32)
# Размещаем данные в правильной позиции (от -10 до 1 ГГц - нули, от 1 до 10 ГГц - данные)
fft_in[start_idx:start_idx + data_points] = seg * win
# Полное FFT (включая отрицательные частоты)
spec = np.fft.fft(fft_in)
# Сдвигаем для центрирования нулевой частоты
spec = np.fft.fftshift(spec)
mag = np.abs(spec).astype(np.float32)
fft_row = 20.0 * np.log10(mag + 1e-9)
if fft_row.shape[0] != bins:
fft_row = fft_row[:bins]
# Расчет фазы
phase = np.angle(spec).astype(np.float32)
if phase.shape[0] > bins:
phase = phase[:bins]
# Unwrapping по частоте (внутри свипа)
phase_unwrapped_freq = np.unwrap(phase)
# Unwrapping по времени (между свипами)
phase_unwrapped_time, prev_phase_per_bin, phase_offset_per_bin = apply_temporal_unwrap(
phase_unwrapped_freq, prev_phase_per_bin, phase_offset_per_bin
)
phase_row = phase_unwrapped_time
else:
fft_row = np.full((bins,), np.nan, dtype=np.float32)
phase_row = np.full((bins,), np.nan, dtype=np.float32)
ring_fft[(head - 1) % ring_fft.shape[0], :] = fft_row
fr_min = np.nanmin(fft_row)
fr_max = np.nanmax(fft_row)
if y_min_fft is None or (not np.isnan(fr_min) and fr_min < y_min_fft):
y_min_fft = float(fr_min)
if y_max_fft is None or (not np.isnan(fr_max) and fr_max > y_max_fft):
y_max_fft = float(fr_max)
# Сохраняем фазу в буфер
if ring_phase is not None:
ring_phase[(head - 1) % ring_phase.shape[0], :] = phase_row
# Экстремумы для цветовой шкалы фазы
ph_min = np.nanmin(phase_row)
ph_max = np.nanmax(phase_row)
if y_min_phase is None or (not np.isnan(ph_min) and ph_min < y_min_phase):
y_min_phase = float(ph_min)
if y_max_phase is None or (not np.isnan(ph_max) and ph_max > y_max_phase):
y_max_phase = float(ph_max)
def drain_queue():
nonlocal current_sweep, current_info
drained = 0
while True:
try:
s, info = q.get_nowait()
except Empty:
break
drained += 1
current_sweep = s
current_info = info
ensure_buffer(s.size)
push_sweep(s)
return drained
# Попытка применить LUT из колормэпа (если доступен)
try:
cm_mod = getattr(pg, "colormap", None)
if cm_mod is not None:
cm = cm_mod.get(args.cmap)
img.setLookupTable(cm.getLookupTable(0.0, 1.0, 256))
except Exception:
pass
def update():
nonlocal ref_out_saved
changed = drain_queue() > 0
if current_sweep is not None and x_shared is not None:
# Применяем вычитание медианы для отображения
display_sweep = current_sweep
if median_subtract_enabled and median_data is not None:
take_median = min(current_sweep.size, median_data.size)
display_sweep = current_sweep.copy()
display_sweep[:take_median] = current_sweep[:take_median] - median_data[:take_median]
if display_sweep.size <= x_shared.size:
xs = x_shared[: display_sweep.size]
else:
xs = np.arange(display_sweep.size)
curve.setData(xs, display_sweep, autoDownsample=True)
if fixed_ylim is None:
y0 = float(np.nanmin(display_sweep))
y1 = float(np.nanmax(display_sweep))
if np.isfinite(y0) and np.isfinite(y1):
margin = 0.05 * max(1.0, (y1 - y0))
p_line.setYRange(y0 - margin, y1 + margin, padding=0)
# Обновим спектр и фазу
take_fft = min(int(display_sweep.size), FFT_LEN)
if take_fft > 0 and freq_shared is not None:
# Создаем буфер для полного FFT (с отрицательными частотами)
fft_in = np.zeros((FFT_LEN,), dtype=np.float32)
# Вычисляем индексы для размещения данных (1-10 ГГц в диапазоне -10 до +10 ГГц)
freq_range_total = FREQ_MAX_GHZ - FREQ_MIN_GHZ # 20 ГГц
freq_range_data = DATA_FREQ_END_GHZ - DATA_FREQ_START_GHZ # 9 ГГц
# Начальный индекс для данных в FFT буфере
start_idx = int((DATA_FREQ_START_GHZ - FREQ_MIN_GHZ) / freq_range_total * FFT_LEN)
# Количество точек для данных
data_points = int(freq_range_data / freq_range_total * FFT_LEN)
data_points = min(data_points, take_fft, FFT_LEN - start_idx)
# Подготовка данных с окном Хэннинга
seg = np.nan_to_num(display_sweep[:data_points], nan=0.0).astype(np.float32, copy=False)
win = np.hanning(data_points).astype(np.float32)
# Размещаем данные в правильной позиции
fft_in[start_idx:start_idx + data_points] = seg * win
# Полное FFT (включая отрицательные частоты)
spec = np.fft.fft(fft_in)
# Сдвигаем для центрирования нулевой частоты
spec = np.fft.fftshift(spec)
mag = np.abs(spec).astype(np.float32)
fft_vals = 20.0 * np.log10(mag + 1e-9)
xs_fft = freq_shared
if fft_vals.size > xs_fft.size:
fft_vals = fft_vals[: xs_fft.size]
curve_fft.setData(xs_fft[: fft_vals.size], fft_vals)
p_fft.setYRange(float(np.nanmin(fft_vals)), float(np.nanmax(fft_vals)), padding=0)
# Расчет и отображение фазы текущего свипа
phase = np.angle(spec).astype(np.float32)
if phase.size > xs_fft.size:
phase = phase[: xs_fft.size]
# Unwrapping по частоте
phase_unwrapped = np.unwrap(phase)
curve_phase.setData(xs_fft[: phase_unwrapped.size], phase_unwrapped)
phase_min = float(np.nanmin(phase_unwrapped))
phase_max = float(np.nanmax(phase_unwrapped))
p_phase.setYRange(phase_min, phase_max, padding=0)
# Обновляем вторую ось Y с расстоянием
try:
dist_min = phase_to_distance(np.array([phase_min]))[0]
dist_max = phase_to_distance(np.array([phase_max]))[0]
p_phase_dist_axis.setYRange(dist_min, dist_max, padding=0)
except Exception:
pass
if changed and ring is not None:
disp = ring if head == 0 else np.roll(ring, -head, axis=0)
disp = disp.T[:, ::-1] # (width, time with newest at left)
levels = _visible_levels_pyqtgraph(disp)
if levels is not None:
img.setImage(disp, autoLevels=False, levels=levels)
else:
img.setImage(disp, autoLevels=False)
if changed and current_info:
try:
status.setText(format_status_kv(current_info))
except Exception:
pass
# Автоматическое сохранение медианы при накоплении 1000 сырых свипов
if ref_out_file and not ref_out_saved and ref_ring is not None:
if ref_ring_count >= 1000:
try:
ordered = ref_ring if ref_ring_head == 0 else np.roll(ref_ring, -ref_ring_head, axis=0)
median_sweep = np.nanmedian(ordered, axis=0)
with open(ref_out_file, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(['Index', 'Median_Value'])
for i, value in enumerate(median_sweep):
if np.isfinite(value):
writer.writerow([i, float(value)])
ref_out_saved = True
print(f"[ref-out] Сохранена медиана 1000 свипов в {ref_out_file}")
if status:
status.setText(f"[ref-out] Сохранено в {ref_out_file}")
except Exception as e:
print(f"[ref-out] Ошибка сохранения: {e}")
if changed and ring_fft is not None:
disp_fft = ring_fft if head == 0 else np.roll(ring_fft, -head, axis=0)
disp_fft = disp_fft.T[:, ::-1]
# Автодиапазон по среднему спектру за видимый интервал (как в хорошей версии)
levels = None
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:
levels = (vmin_v, vmax_v)
except Exception:
levels = None
# Процентильная обрезка как запасной вариант
if levels is None and 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:
levels = (vmin_v, vmax_v)
except Exception:
levels = None
# Ещё один фолбэк — глобальные накопленные мин/макс
if levels is None and y_min_fft is not None and y_max_fft is not None and np.isfinite(y_min_fft) and np.isfinite(y_max_fft) and y_min_fft != y_max_fft:
levels = (y_min_fft, y_max_fft)
if levels is not None:
img_fft.setImage(disp_fft, autoLevels=False, levels=levels)
else:
img_fft.setImage(disp_fft, autoLevels=False)
# Обновление водопада фазы
if changed and ring_phase is not None:
disp_phase = ring_phase if head == 0 else np.roll(ring_phase, -head, axis=0)
disp_phase = disp_phase.T[:, ::-1]
# Автодиапазон для фазы
levels_phase = None
try:
mean_phase = np.nanmean(disp_phase, axis=1)
vmin_p = float(np.nanmin(mean_phase))
vmax_p = float(np.nanmax(mean_phase))
if np.isfinite(vmin_p) and np.isfinite(vmax_p) and vmin_p != vmax_p:
levels_phase = (vmin_p, vmax_p)
except Exception:
levels_phase = None
# Фолбэк к отслеживаемым минимум/максимумам
if levels_phase is None and y_min_phase is not None and y_max_phase is not None and np.isfinite(y_min_phase) and np.isfinite(y_max_phase) and y_min_phase != y_max_phase:
levels_phase = (y_min_phase, y_max_phase)
if levels_phase is not None:
img_phase.setImage(disp_phase, autoLevels=False, levels=levels_phase)
else:
img_phase.setImage(disp_phase, autoLevels=False)
timer = pg.QtCore.QTimer()
timer.timeout.connect(update)
timer.start(interval_ms)
def on_quit():
stop_event.set()
reader.join(timeout=1.0)
app.aboutToQuit.connect(on_quit)
win.show()
exec_fn = getattr(app, "exec_", None) or getattr(app, "exec", None)
exec_fn()
# На случай если aboutToQuit не сработал
on_quit()

9
run.py Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env python3
"""
Скрипт запуска RFG ADC Data Plotter.
"""
from rfg_adc_plotter.cli import main
if __name__ == "__main__":
main()

6447
test2.ipynb Normal file

File diff suppressed because one or more lines are too long

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,42 +0,0 @@
from __future__ import annotations
import subprocess
import sys
import unittest
from pathlib import Path
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_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)
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,275 +0,0 @@
from __future__ import annotations
import os
import tempfile
import numpy as np
import unittest
from rfg_adc_plotter.constants import FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ
from rfg_adc_plotter.gui.pyqtgraph_backend import (
apply_working_range,
apply_working_range_to_aux_curves,
compute_background_subtracted_bscan_levels,
resolve_visible_aux_curves,
)
from rfg_adc_plotter.processing.calibration import (
build_calib_envelope,
calibrate_freqs,
load_calib_envelope,
recalculate_calibration_c,
save_calib_envelope,
)
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_centered_ifft_spectrum,
build_symmetric_ifft_spectrum,
compute_distance_axis,
compute_fft_mag_row,
compute_fft_row,
fft_mag_to_db,
)
from rfg_adc_plotter.processing.normalization import (
build_calib_envelopes,
normalize_by_calib,
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_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_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_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_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_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_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,90 +0,0 @@
from __future__ import annotations
import numpy as np
import unittest
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_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_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)
if __name__ == "__main__":
unittest.main()

View File

@ -1,159 +0,0 @@
from __future__ import annotations
import math
import unittest
from rfg_adc_plotter.io.sweep_parser_core import (
AsciiSweepParser,
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),
]
)
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_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_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.assertEqual(events[1].aux, (100.0, 90.0))
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.assertTrue(math.isfinite(events[1].y))
def test_sweep_assembler_builds_aux_curves_without_inversion(self):
assembler = SweepAssembler(fancy=False, apply_inversion=False)
self.assertIsNone(assembler.consume(StartEvent(ch=1)))
assembler.consume(PointEvent(ch=1, x=1, y=10.0, aux=(100.0, 90.0)))
assembler.consume(PointEvent(ch=1, x=2, y=20.0, aux=(110.0, 95.0)))
sweep, info, aux = assembler.finalize_current()
self.assertEqual(sweep.shape[0], 3)
self.assertEqual(info["ch"], 1)
self.assertIsNotNone(aux)
self.assertEqual(aux[0][1], 100.0)
self.assertEqual(aux[1][2], 95.0)
if __name__ == "__main__":
unittest.main()