diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ea22f42 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `main.py`: Tkinter GUI for radar data analysis; watches a data folder, parses RAW/SYNC_DET/FOURIER files, and renders B‑scan/Fourier views. +- `datagen.py`: Test data generator for RAW, SYNC_DET, and FOURIER; produces time‑stamped files to feed the GUI. +- `testLadArrayGround.m`: MATLAB scratch for algorithm experiments. +- Tests: none yet. Add under `tests/` (e.g., `tests/test_io.py`). If processing grows, factor helpers into a `processing/` package (e.g., `processing/signal.py`). + +## Build, Test, and Development Commands +- Create venv: `python3 -m venv .venv && source .venv/bin/activate` (Win: `.venv\Scripts\activate`). +- Install deps: `pip install numpy scipy matplotlib`. Linux may require Tk: `sudo apt-get install -y python3-tk`. +- Run GUI: `python main.py`. +- Generate sample data: `python datagen.py` and choose 1/2/3; files go to the configured data folder. +- Run tests (when added): `pytest -q`. + +## Coding Style & Naming Conventions +- Follow PEP 8 with 4‑space indents; add type hints for new/edited functions. +- Naming: snake_case (functions/vars), PascalCase (classes), UPPER_SNAKE_CASE (constants). +- Keep GUI logic in `main.py`; move pure processing/IO into small, testable modules. + +## Testing Guidelines +- Framework: pytest (recommended). No suite exists yet. +- Naming: `tests/test_*.py`. Use temp dirs for file‑based tests; don’t write to system paths. +- Aim for ≥70% coverage on new modules. Add smoke tests for file parsing and queue/processing logic; use a non‑interactive Matplotlib backend for tests. + +## Commit & Pull Request Guidelines +- Commits: imperative mood with scope, e.g., `gui: improve B‑scan update`, `datagen: add FOURIER mode`. +- PRs: include description, linked issues, reproduction steps, and screenshots/GIFs for UI changes. Keep changes focused and update docs when constants/paths change. + +## Configuration Tips +- Data paths are hardcoded; update before running on non‑Windows systems: + - `main.py:18` — `data_dir = r"D:\\data"` + - `datagen.py:11` — `DATA_DIR = r"D:\\data"` +- Prefer a writable local path (e.g., `/tmp/data`) and do not commit generated data. + diff --git a/datagen.py b/datagen.py old mode 100644 new mode 100755 index 2572451..b475cc4 --- a/datagen.py +++ b/datagen.py @@ -1,294 +1,333 @@ -import os -import time -import numpy as np -from datetime import datetime, timedelta - -# ================================================================================ -# ПАРАМЕТРЫ ЭМУЛЯЦИИ -# ================================================================================ - -DATA_DIR = r"D:\data" - -# ✓ ИСПРАВЛЕНИЕ: Выбор типа данных и количество -NUM_FILES = 1000 # Количесйтво файлов для генерации - -# Размеры данных -RAW_SIZE = 64000 -SYNC_DET_SIZE = 1000 -FOURIER_SIZE = SYNC_DET_SIZE // 2 # 500 (положительные частоты) - -# Интервал между файлами (в миллисекундах) -FILE_INTERVAL_MS = 300 # 300 мс между файлами - - -# ================================================================================ -# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ -# ================================================================================ - -def create_raw_data(size=RAW_SIZE, index=0): - """Генерирует RAW данные.""" - # Синусоида + шум, зависит от индекса для разнообразия - t = np.linspace(0, 10 * np.pi, size) - freq_mult = 1.0 + 0.1 * np.sin(index / 100) - signal = np.sin(freq_mult * t) + 0.1 * np.random.randn(size) - return signal - - -def create_sync_det_data(size=SYNC_DET_SIZE, index=0): - """Генерирует SYNC_DET данные.""" - # Модулированная синусоида, зависит от индекса - t = np.linspace(0, 20 * np.pi, size) - damping = np.exp(-t / (20 * np.pi)) * (1 + 0.2 * np.sin(index / 50)) - signal = np.sin(t) * damping - return signal - - -def create_fourier_data(sync_det_data=None, fft_size=SYNC_DET_SIZE, output_size=FOURIER_SIZE, index=0): - """✓ Генерирует FOURIER = |FFT(SYNC_DET)|[:N/2]. - - FFT от сигнала размером N имеет только N/2 независимых значений. - - Args: - sync_det_data: вектор SYNC_DET размером ~1000 - fft_size: размер FFT (1000) - output_size: размер выходного спектра (500) - index: индекс файла для вариативности - - Returns: - fourier_data: амплитудный спектр размером output_size (500) - """ - - if sync_det_data is None: - sync_det_data = create_sync_det_data(index=index) - - # ✓ Вычисляем FFT от SYNC_DET - fft_result = np.fft.fft(sync_det_data[:fft_size]) - - # ✓ Берём амплитудный спектр - amplitude_spectrum = np.abs(fft_result) - - # ✓ Центрируем спектр - fft_shift = np.fft.fftshift(amplitude_spectrum) - - # ✓ Берём только положительные частоты (N/2 = 500) - fft_positive = fft_shift[len(fft_shift) // 2:] - - assert len(fft_positive) == output_size, \ - f"FFT positive frequencies size {len(fft_positive)} != expected {output_size}" - - fourier_data = fft_positive.astype(float) - - # Нормализуем - if fourier_data.max() > 0: - fourier_data = fourier_data / fourier_data.max() * 100 - - return fourier_data - - -# ================================================================================ -# ГЕНЕРАЦИЯ ФАЙЛОВ -# ================================================================================ - -def emit_raw_files(count=NUM_FILES, start_time=None): - """Генерирует RAW файлы количеством count.""" - if start_time is None: - start_time = datetime.now() - - print(f"\n{'=' * 80}") - print(f"📝 ГЕНЕРИРОВАНИЕ {count} RAW ФАЙЛОВ") - print(f"{'=' * 80}") - print(f"Размер: {RAW_SIZE} точек") - print(f"Интервал: {FILE_INTERVAL_MS}мс\n") - - for i in range(count): - # Вычисляем время файла - file_time = start_time + timedelta(milliseconds=i * FILE_INTERVAL_MS) - - # Форматируем имя с миллисекундами - time_str = file_time.strftime("%H_%M_%S") - ms = file_time.microsecond // 1000 - filename = f"RAW_{time_str}_{ms:03d}.txt" - - # Генерируем данные - data = create_raw_data(index=i) - - # Добавляем заголовок - filepath = os.path.join(DATA_DIR, filename) - with open(filepath, 'w') as f: - f.write("RAW\n") - np.savetxt(f, data, fmt='%.6f') - - # Устанавливаем время модификации - timestamp = file_time.timestamp() - os.utime(filepath, (timestamp, timestamp)) - - # Прогресс - if (i + 1) % 100 == 0 or (i + 1) == count: - print(f" ✓ {i + 1}/{count} файлов созданы", end='\r') - - print(f"\n✅ Готово: {count} RAW файлов") - return start_time + timedelta(milliseconds=count * FILE_INTERVAL_MS) - - -def emit_sync_det_files(count=NUM_FILES, start_time=None): - """Генерирует SYNC_DET файлы количеством count.""" - if start_time is None: - start_time = datetime.now() - - print(f"\n{'=' * 80}") - print(f"📝 ГЕНЕРИРОВАНИЕ {count} SYNC_DET ФАЙЛОВ") - print(f"{'=' * 80}") - print(f"Размер: {SYNC_DET_SIZE} точек") - print(f"Интервал: {FILE_INTERVAL_MS}мс\n") - - for i in range(count): - # Вычисляем время файла - file_time = start_time + timedelta(milliseconds=i * FILE_INTERVAL_MS) - - # Форматируем имя с миллисекундами - time_str = file_time.strftime("%H_%M_%S") - ms = file_time.microsecond // 1000 - filename = f"SYNC_DET_{time_str}_{ms:03d}.txt" - - # Генерируем данные - data = create_sync_det_data(index=i) - - # Добавляем заголовок - filepath = os.path.join(DATA_DIR, filename) - with open(filepath, 'w') as f: - f.write("SYNC_DET\n") - np.savetxt(f, data, fmt='%.6f') - - # Устанавливаем время модификации - timestamp = file_time.timestamp() - os.utime(filepath, (timestamp, timestamp)) - - # Прогресс - if (i + 1) % 100 == 0 or (i + 1) == count: - print(f" ✓ {i + 1}/{count} файлов созданы", end='\r') - - print(f"\n✅ Готово: {count} SYNC_DET файлов") - return start_time + timedelta(milliseconds=count * FILE_INTERVAL_MS) - - -def emit_fourier_files(count=NUM_FILES, start_time=None): - """✓ Генерирует FOURIER файлы количеством count. - - Каждый файл содержит амплитудный спектр размером 500. - """ - if start_time is None: - start_time = datetime.now() - - print(f"\n{'=' * 80}") - print(f"📝 ГЕНЕРИРОВАНИЕ {count} FOURIER ФАЙЛОВ") - print(f"{'=' * 80}") - print(f"Размер: {FOURIER_SIZE} точек (|FFT|[:N/2])") - print(f"Интервал: {FILE_INTERVAL_MS}мс\n") - - for i in range(count): - # Вычисляем время файла - file_time = start_time + timedelta(milliseconds=i * FILE_INTERVAL_MS) - - # Форматируем имя с миллисекундами - time_str = file_time.strftime("%H_%M_%S") - ms = file_time.microsecond // 1000 - filename = f"FOURIER_{time_str}_{ms:03d}.txt" - - # Генерируем SYNC_DET - sync_det = create_sync_det_data(index=i) - - # Вычисляем FOURIER как |FFT(SYNC_DET)|[:N/2] - data = create_fourier_data( - sync_det_data=sync_det, - fft_size=SYNC_DET_SIZE, - output_size=FOURIER_SIZE, - index=i - ) - - assert len(data) == FOURIER_SIZE, \ - f"FOURIER size {len(data)} != expected {FOURIER_SIZE}" - - # Добавляем заголовок - filepath = os.path.join(DATA_DIR, filename) - with open(filepath, 'w') as f: - f.write("FOURIER\n") - np.savetxt(f, data, fmt='%.6f') - - # Устанавливаем время модификации - timestamp = file_time.timestamp() - os.utime(filepath, (timestamp, timestamp)) - - # Прогресс - if (i + 1) % 100 == 0 or (i + 1) == count: - print(f" ✓ {i + 1}/{count} файлов созданы", end='\r') - - print(f"\n✅ Готово: {count} FOURIER файлов") - return start_time + timedelta(milliseconds=count * FILE_INTERVAL_MS) - - -# ================================================================================ -# ОСНОВНАЯ ПРОГРАММА -# ================================================================================ - -def show_menu(): - """Показывает меню выбора типа данных.""" - print("\n" + "=" * 80) - print(" РАДАР ЭМУЛЯТОР - ВЫБОР ТИПА ДАННЫХ") - print("=" * 80) - print() - print(" Выберите тип данных для генерирования:") - print() - print(" 1. RAW - Сырые данные с АЦП ({} точек)".format(RAW_SIZE)) - print(" 2. SYNC_DET - Обработанные данные ({} точек)".format(SYNC_DET_SIZE)) - print(" 3. FOURIER - Амплитудный спектр ({} точек)".format(FOURIER_SIZE)) - print() - print("=" * 80) - print() - - -if __name__ == "__main__": - # Создаём директорию если её нет - os.makedirs(DATA_DIR, exist_ok=True) - - print("\n" + "=" * 80) - print(" РАДАР ЭМУЛЯТОР - ГЕНЕРИРОВАНИЕ ТЕСТОВЫХ ДАННЫХ") - print("=" * 80) - print(f" Директория: {DATA_DIR}") - print(f" Количество файлов: {NUM_FILES}") - print(f" Интервал между файлами: {FILE_INTERVAL_MS}мс") - print(f" Формат времени: HH:MM:SS.mmm") - print("=" * 80) - - show_menu() - - while True: - try: - choice = input(" Введите номер (1/2/3) или 'q' для выхода: ").strip().lower() - - if choice == 'q': - print("\n Выход.") - break - elif choice == '1': - start_time = datetime.now().replace(microsecond=0) - emit_raw_files(NUM_FILES, start_time) - break - elif choice == '2': - start_time = datetime.now().replace(microsecond=0) - emit_sync_det_files(NUM_FILES, start_time) - break - elif choice == '3': - start_time = datetime.now().replace(microsecond=0) - emit_fourier_files(NUM_FILES, start_time) - break - else: - print(" ❌ Неверный выбор. Введите 1, 2, 3 или q") - except KeyboardInterrupt: - print("\n\n Прервано пользователем.") - break - except Exception as e: - print(f" ❌ Ошибка: {e}") - continue - - print(f"\n📂 Файлы сохранены в: {DATA_DIR}") - print(f"\n💡 Запустите main_analyzer.py и откройте директорию {DATA_DIR}") - print(f" Анализатор автоматически подхватит новые файлы\n") \ No newline at end of file +#!/usr/bin/python3 +import os +import time +import numpy as np +from datetime import datetime, timedelta +#from builtins import True + +# ================================================================================ +# ПАРАМЕТРЫ ЭМУЛЯЦИИ +# ================================================================================ + +#DATA_DIR = r"D:\data" +DATA_DIR = './data' + +# ✓ ИСПРАВЛЕНИЕ: Выбор типа данных и количество +NUM_FILES = 1000 # Количесйтво файлов для генерации + +# Размеры данных +RAW_SIZE = 64000 +SYNC_DET_SIZE = 1000 +FOURIER_SIZE = SYNC_DET_SIZE // 2 # 500 (положительные частоты) + +# Интервал между файлами (в миллисекундах) +FILE_INTERVAL_MS = 300 # 300 мс между файлами + + +# ================================================================================ +# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ +# ================================================================================ + +def create_raw_data(size=RAW_SIZE, index=0): + """Генерирует RAW данные.""" + # Синусоида + шум, зависит от индекса для разнообразия + t = np.linspace(0, 10 * np.pi, size) + freq_mult = 1.0 + 0.1 * np.sin(index / 100) + signal = np.sin(freq_mult * t) + 0.1 * np.random.randn(size) + return signal + + +def create_sync_det_data(size=SYNC_DET_SIZE, index=0): + """Генерирует SYNC_DET данные.""" + # Модулированная синусоида, зависит от индекса + t = np.linspace(0, 20 * np.pi, size) + damping = np.exp(-t / (20 * np.pi)) * (1 + 0.2 * np.sin(index / 50)) + signal = np.sin(t) * damping + return signal + + +def create_fourier_data(sync_det_data=None, fft_size=SYNC_DET_SIZE, output_size=FOURIER_SIZE, index=0): + """✓ Генерирует FOURIER = |FFT(SYNC_DET)|[:N/2]. + + FFT от сигнала размером N имеет только N/2 независимых значений. + + Args: + sync_det_data: вектор SYNC_DET размером ~1000 + fft_size: размер FFT (1000) + output_size: размер выходного спектра (500) + index: индекс файла для вариативности + + Returns: + fourier_data: амплитудный спектр размером output_size (500) + """ + + if sync_det_data is None: + sync_det_data = create_sync_det_data(index=index) + + # ✓ Вычисляем FFT от SYNC_DET + fft_result = np.fft.fft(sync_det_data[:fft_size]) + + # ✓ Берём амплитудный спектр + amplitude_spectrum = np.abs(fft_result) + + # ✓ Центрируем спектр + fft_shift = np.fft.fftshift(amplitude_spectrum) + + # ✓ Берём только положительные частоты (N/2 = 500) + fft_positive = fft_shift[len(fft_shift) // 2:] + + assert len(fft_positive) == output_size, \ + f"FFT positive frequencies size {len(fft_positive)} != expected {output_size}" + + fourier_data = fft_positive.astype(float) + + # Нормализуем + if fourier_data.max() > 0: + fourier_data = fourier_data / fourier_data.max() * 100 + + return fourier_data + + +# ================================================================================ +# ГЕНЕРАЦИЯ ФАЙЛОВ +# ================================================================================ + +def emit_raw_files(count=NUM_FILES, start_time=None, hex_mode=True): + """Генерирует RAW файлы количеством count.""" + if start_time is None: + start_time = datetime.now() + + print(f"\n{'=' * 80}") + print(f"📝 ГЕНЕРИРОВАНИЕ {count} RAW ФАЙЛОВ") + print(f"{'=' * 80}") + print(f"Размер: {RAW_SIZE} точек") + print(f"Интервал: {FILE_INTERVAL_MS}мс\n") + + for i in range(count): + # Вычисляем время файла + file_time = start_time + timedelta(milliseconds=i * FILE_INTERVAL_MS) + + # Форматируем имя с миллисекундами + time_str = file_time.strftime("%H_%M_%S") + ms = file_time.microsecond // 1000 + filename = f"RAW_{time_str}_{ms:03d}.txt" + + # Генерируем данные + data = create_raw_data(index=i) + + # Добавляем заголовок + filepath = os.path.join(DATA_DIR, filename) + with open(filepath, 'w') as f: + + + if hex_mode: + #f.write("SYNC_DET_HEX\n") + #np.savetxt(f, np.uint32(data*1000), fmt='0xD0%06X') + np.savetxt(f, ((data * 1000).astype(np.int32) & 0xFFFFFF), fmt='0xD0%06X') + + else: + f.write("RAW\n") + np.savetxt(f, data, fmt='%.6f') + + + # Устанавливаем время модификации + timestamp = file_time.timestamp() + os.utime(filepath, (timestamp, timestamp)) + + # Прогресс + if (i + 1) % 100 == 0 or (i + 1) == count: + print(f" ✓ {i + 1}/{count} файлов созданы", end='\r') + + print(f"\n✅ Готово: {count} RAW файлов") + return start_time + timedelta(milliseconds=count * FILE_INTERVAL_MS) + + +def emit_sync_det_files(count=NUM_FILES, start_time=None, hex_mode=True): + """Генерирует SYNC_DET файлы количеством count.""" + if start_time is None: + start_time = datetime.now() + + print(f"\n{'=' * 80}") + print(f"📝 ГЕНЕРИРОВАНИЕ {count} SYNC_DET ФАЙЛОВ") + print(f"{'=' * 80}") + print(f"Размер: {SYNC_DET_SIZE} точек") + print(f"Интервал: {FILE_INTERVAL_MS}мс\n") + + for i in range(count): + # Вычисляем время файла + file_time = start_time + timedelta(milliseconds=i * FILE_INTERVAL_MS) + + # Форматируем имя с миллисекундами + time_str = file_time.strftime("%H_%M_%S") + ms = file_time.microsecond // 1000 + filename = f"SYNC_DET_{time_str}_{ms:03d}.txt" + + # Генерируем данные + data = create_sync_det_data(index=i) + #print("data:", data) + + # Добавляем заголовок + filepath = os.path.join(DATA_DIR, filename) + with open(filepath, 'w') as f: + + if hex_mode: + #f.write("SYNC_DET_HEX\n") + #np.savetxt(f, np.uint32(data*1000), fmt='0xF0%06X') + np.savetxt(f, ((data * 1000).astype(np.int32) & 0xFFFFFF), fmt='0xF0%06X') + else: + f.write("SYNC_DET\n") + np.savetxt(f, data, fmt='%.6f') + + # Устанавливаем время модификации + timestamp = file_time.timestamp() + os.utime(filepath, (timestamp, timestamp)) + + # Прогресс + if (i + 1) % 100 == 0 or (i + 1) == count: + print(f" ✓ {i + 1}/{count} файлов созданы", end='\r') + + print(f"\n✅ Готово: {count} SYNC_DET файлов") + return start_time + timedelta(milliseconds=count * FILE_INTERVAL_MS) + + +def emit_fourier_files(count=NUM_FILES, start_time=None, hex_mode=True): + """✓ Генерирует FOURIER файлы количеством count. + + Каждый файл содержит амплитудный спектр размером 500. + """ + if start_time is None: + start_time = datetime.now() + + print(f"\n{'=' * 80}") + print(f"📝 ГЕНЕРИРОВАНИЕ {count} FOURIER ФАЙЛОВ") + print(f"{'=' * 80}") + print(f"Размер: {FOURIER_SIZE} точек (|FFT|[:N/2])") + print(f"Интервал: {FILE_INTERVAL_MS}мс\n") + + for i in range(count): + # Вычисляем время файла + file_time = start_time + timedelta(milliseconds=i * FILE_INTERVAL_MS) + + # Форматируем имя с миллисекундами + time_str = file_time.strftime("%H_%M_%S") + ms = file_time.microsecond // 1000 + filename = f"FOURIER_{time_str}_{ms:03d}.txt" + + # Генерируем SYNC_DET + sync_det = create_sync_det_data(index=i) + + # Вычисляем FOURIER как |FFT(SYNC_DET)|[:N/2] + data = create_fourier_data( + sync_det_data=sync_det, + fft_size=SYNC_DET_SIZE, + output_size=FOURIER_SIZE, + index=i + ) + + assert len(data) == FOURIER_SIZE, \ + f"FOURIER size {len(data)} != expected {FOURIER_SIZE}" + + # Добавляем заголовок + filepath = os.path.join(DATA_DIR, filename) + with open(filepath, 'w') as f: + + if hex_mode: + #f.write("SYNC_DET_HEX\n") + #np.savetxt(f, np.uint32(data*1000), fmt='0xF4%06X') + np.savetxt(f, ((data * 1000).astype(np.int32) & 0xFFFFFF), fmt='0xF4%06X') + else: + f.write("FOURIER\n") + np.savetxt(f, data, fmt='%.6f') + + + # Устанавливаем время модификации + timestamp = file_time.timestamp() + os.utime(filepath, (timestamp, timestamp)) + + # Прогресс + if (i + 1) % 100 == 0 or (i + 1) == count: + print(f" ✓ {i + 1}/{count} файлов созданы", end='\r') + + print(f"\n✅ Готово: {count} FOURIER файлов") + return start_time + timedelta(milliseconds=count * FILE_INTERVAL_MS) + + +# ================================================================================ +# ОСНОВНАЯ ПРОГРАММА +# ================================================================================ + +def show_menu(): + """Показывает меню выбора типа данных.""" + print("\n" + "=" * 80) + print(" РАДАР ЭМУЛЯТОР - ВЫБОР ТИПА ДАННЫХ") + print("=" * 80) + print() + print(" Выберите тип данных для генерирования:") + print() + print(" 1. RAW - Сырые данные с АЦП ({} точек)".format(RAW_SIZE)) + print(" 2. SYNC_DET - Обработанные данные ({} точек)".format(SYNC_DET_SIZE)) + print(" 3. FOURIER - Амплитудный спектр ({} точек)".format(FOURIER_SIZE)) + print() + print("=" * 80) + print() + + +if __name__ == "__main__": + # Создаём директорию если её нет + os.makedirs(DATA_DIR, exist_ok=True) + + print("\n" + "=" * 80) + print(" РАДАР ЭМУЛЯТОР - ГЕНЕРИРОВАНИЕ ТЕСТОВЫХ ДАННЫХ") + print("=" * 80) + print(f" Директория: {DATA_DIR}") + print(f" Количество файлов: {NUM_FILES}") + print(f" Интервал между файлами: {FILE_INTERVAL_MS}мс") + print(f" Формат времени: HH:MM:SS.mmm") + print("=" * 80) + + show_menu() + + + while True: + try: + hex_choice = input(" Генерировать в HEX (h) или float (f)? 'q' для выхода: ").strip().lower() + if hex_choice == "h": + hex_mode = True + elif hex_choice == "f": + hex_mode = False + else: + print(" ❌ Неверный выбор. Введите h, f или q") + + + + choice = input(" Введите номер (1/2/3) или 'q' для выхода: ").strip().lower() + + if choice == 'q': + print("\n Выход.") + break + elif choice == '1': + start_time = datetime.now().replace(microsecond=0) + emit_raw_files(NUM_FILES, start_time, hex_mode=hex_mode) + break + elif choice == '2': + start_time = datetime.now().replace(microsecond=0) + emit_sync_det_files(NUM_FILES, start_time, hex_mode=hex_mode) + break + elif choice == '3': + start_time = datetime.now().replace(microsecond=0) + emit_fourier_files(NUM_FILES, start_time, hex_mode=hex_mode) + break + else: + print(" ❌ Неверный выбор. Введите 1, 2, 3 или q") + + + except KeyboardInterrupt: + print("\n\n Прервано пользователем.") + break + except Exception as e: + print(f" ❌ Ошибка: {e}") + continue + + print(f"\n📂 Файлы сохранены в: {DATA_DIR}") + print(f"\n💡 Запустите main_analyzer.py и откройте директорию {DATA_DIR}") + print(f" Анализатор автоматически подхватит новые файлы\n") diff --git a/main.py b/main.py index c05d740..e10262a 100755 --- a/main.py +++ b/main.py @@ -1,1239 +1,1585 @@ -#!/usr/bin/python3 -import os -import time -import numpy as np -import tkinter as tk -from tkinter import ttk -import matplotlib.pyplot as plt -from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg -from scipy.signal import square -from scipy.ndimage import gaussian_filter1d -from scipy.interpolate import interp1d -from datetime import datetime, timedelta -import threading -import queue - -# ================================================================================ -# ПАРАМЕТРЫ И КОНСТАНТЫ -# ================================================================================ -#data_dir = r"D:\data" -data_dir = "/home/awe/Documents/E502_ADC_BF_PC_companion/tmp" -PeriodIntegrate = 2 -pontInOneFqChange = 86 - -# Высота B-скана для RAW/SYNC_DET обработки -height = 60 - -timePoint = 100 -FQend = 512 -FFT0_delta = 5 - -STANDARD_RAW_SIZE = 64000 -STANDARD_SYNC_SIZE = 1000 - -# FOURIER размер = SYNC_DET_SIZE // 2 (положительные частоты) -STANDARD_FOURIER_SIZE = STANDARD_SYNC_SIZE // 2 # 1000 // 2 = 500 - -STANDARD_FOURIER_ROWS = 60 -STANDARD_FOURIER_COLS = 100 - -MAX_PROCESSING_TIME_MS = 250 - -# ПЕРЕЧИСЛЕНИЕ ТИПОВ ДАННЫХ -DATA_TYPE_RAW = "RAW" -DATA_TYPE_SYNC_DET = "SYNC_DET" -DATA_TYPE_FOURIER = "FOURIER" -DATA_TYPE_HEX = "HEX" - -# Режим обработки FOURIER файлов -FOURIER_MODE = 'collapse_mean' -FOURIER_SUBSAMPLE_K = 10 - -# Начальный интервал между файлами (в миллисекундах) -DEFAULT_FILE_INTERVAL_MS = 300 # 300 мс - -# Коэффициент для определения разрыва (1.5x интервала) -GAP_THRESHOLD_MULTIPLIER = 1.5 - -# Начальное время опроса файлов (в миллисекундах) -DEFAULT_FILE_POLL_INTERVAL_MS = 100 # 100 мс - - -# ================================================================================ -# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ -# ================================================================================ - -def detect_data_type(first_line): - """Определяет тип данных по первой строке файла. - - Логика: если первая строка начинается с ключевого слова RAW/SYNC_DET/FOURIER/FFT, - считаем соответствующий тип. Иначе — HEX. - """ - try: - up = first_line.strip().upper() - if up.startswith('RAW'): - return DATA_TYPE_RAW - if up.startswith('SYNC_DET') or up.startswith('SYNC DET'): - return DATA_TYPE_SYNC_DET - if up.startswith('FOURIER') or up.startswith('FFT'): - return DATA_TYPE_FOURIER - return DATA_TYPE_HEX - except Exception: - return DATA_TYPE_HEX - - -def resize_1d_interpolate(data, target_size): - """Ресайзит одномерный массив с использованием линейной интерполяции.""" - if len(data) == target_size: - return data - - old_indices = np.linspace(0, 1, len(data)) - new_indices = np.linspace(0, 1, target_size) - - f = interp1d(old_indices, data, kind='linear', fill_value='extrapolate') - return f(new_indices) - - -def resize_2d_interpolate(data, target_rows, target_cols): - """Ресайзит двумерный массив на target_rows x target_cols с интерполяцией.""" - rows, cols = data.shape - - if rows == target_rows and cols == target_cols: - return data - - old_row_indices = np.linspace(0, 1, rows) - new_row_indices = np.linspace(0, 1, target_rows) - f_rows = interp1d(old_row_indices, data, axis=0, kind='linear', fill_value='extrapolate') - data_resampled_rows = f_rows(new_row_indices) - - old_col_indices = np.linspace(0, 1, cols) - new_col_indices = np.linspace(0, 1, target_cols) - f_cols = interp1d(old_col_indices, data_resampled_rows, axis=1, kind='linear', fill_value='extrapolate') - data_resampled = f_cols(new_col_indices) - - return data_resampled - - -def load_data_with_type(filename): - """Загружает данные и определяет их тип по первой строке.""" - with open(filename, 'r') as f: - first_line = f.readline() - - detected_type = detect_data_type(first_line) - - if detected_type != DATA_TYPE_HEX: - try: - data = np.loadtxt(filename, skiprows=1) - except: - data = np.loadtxt(filename) - return detected_type, data - - # HEX формат: строки вида 0xAABBBBBB, где AA — тип, BBBBBB — int24_t - return parse_hex_file(filename) - - -def parse_hex_file(filename): - """Парсит HEX формат с разделением по FE и мапит к RAW/SYNC_DET/FOURIER. - - Возвращает (data_type, data), где data может быть: - - numpy.ndarray (1D) для одного сегмента - - list[numpy.ndarray] для нескольких сегментов (используется для FOURIER, а также RAW/SYNC_DET) - """ - - def to_int24(v): - x = int(v, 16) - if x & 0x800000: - x -= 0x1000000 - return float(x) - - # Текущий накапливаемый сегмент - cur = {"D0": [], "F0": [], "F1": [], "F2": [], "F3": [], "F4": []} - # Списки сегментов по типам данных - seg_raw = [] - seg_sync = [] - seg_fourier = [] - - def finalize_segment(): - nonlocal cur - # Приоритет выбора, что считать сегментом - if cur["F4"]: - seg_fourier.append(np.asarray(cur["F4"], dtype=float)) - elif cur["F3"]: - arr = np.asarray(cur["F3"], dtype=float) - seg_fourier.append(np.sqrt(np.maximum(0.0, arr))) - elif cur["F1"] and cur["F2"] and len(cur["F1"]) == len(cur["F2"]): - re = np.asarray(cur["F1"], dtype=float) - im = np.asarray(cur["F2"], dtype=float) - seg_fourier.append(np.sqrt(re * re + im * im)) - elif cur["F0"]: - seg_sync.append(np.asarray(cur["F0"], dtype=float)) - elif cur["D0"]: - seg_raw.append(np.asarray(cur["D0"], dtype=float)) - # Сброс - cur = {"D0": [], "F0": [], "F1": [], "F2": [], "F3": [], "F4": []} - - with open(filename, 'r') as f: - for line in f: - s = line.strip() - if not s: - continue - # Требование: учитывать только строки, начинающиеся с 0x/0X - if not (s.startswith('0x') or s.startswith('0X')): - continue - h = s[2:] - h = ''.join(ch for ch in h if ch in '0123456789abcdefABCDEF') - if len(h) < 2: - continue - t_byte = h[:2].upper() - - # FE — завершить текущий сегмент - if t_byte == 'FE': - finalize_segment() - continue - - # E0..E9 — игнор - if t_byte.startswith('E') and len(t_byte) == 2 and t_byte[1] in '0123456789': - continue - - # 00 — цифровые биты, пока пропускаем - if t_byte == '00': - continue - - if len(h) < 8: - continue - # Значение 24 бита - val_hex = h[2:8] - try: - value = to_int24(val_hex) - except Exception: - continue - - if t_byte == 'D0': - cur['D0'].append(value) - elif t_byte == 'F0': - cur['F0'].append(value) - elif t_byte == 'F1': - cur['F1'].append(value) - elif t_byte == 'F2': - cur['F2'].append(value) - elif t_byte == 'F3': - cur['F3'].append(value) - elif t_byte == 'F4': - cur['F4'].append(value) - else: - # Неизвестные — пропускаем - continue - - # Финализируем хвост - finalize_segment() - - if seg_fourier: - return DATA_TYPE_FOURIER, seg_fourier - if seg_sync: - # Если несколько, вернём список сегментов - return DATA_TYPE_SYNC_DET, seg_sync if len(seg_sync) > 1 else seg_sync[0] - if seg_raw: - return DATA_TYPE_RAW, seg_raw if len(seg_raw) > 1 else seg_raw[0] - - return DATA_TYPE_RAW, np.asarray([], dtype=float) - - -def get_file_time_with_milliseconds(filename): - """Получает время файла с миллисекундами из имени или mtime.""" - full_path = os.path.join(os.getcwd(), filename) - - milliseconds = 0 - parts = filename.split('_') - if len(parts) >= 4: - try: - last_part = parts[-1].split('.')[0] - if last_part.isdigit() and len(last_part) <= 3: - milliseconds = int(last_part) - except: - pass - - file_mtime = os.path.getmtime(full_path) - file_time = datetime.fromtimestamp(file_mtime) - - if milliseconds > 0: - file_time = file_time.replace(microsecond=milliseconds * 1000) - else: - now = datetime.now() - file_time = file_time.replace(microsecond=now.microsecond) - - return file_time - - -# ================================================================================ -# КЛАСС ПРИЛОЖЕНИЯ -# ================================================================================ - -class DataAnalyzerApp: - def __init__(self, root): - self.root = root - self.root.title("Radar Data Analyzer (Time Synchronized - Queue Based)") - self.root.geometry("1500x850") - - self.data_dir = data_dir - os.makedirs(self.data_dir, exist_ok=True) - os.chdir(self.data_dir) - - # Инициализируем с существующими файлами - existing_files = sorted([ - f for f in os.listdir() - if f.lower().endswith(('.txt', '.txt1', '.txt2', '.csv')) - ]) - self.processed_files = set(existing_files) - - if existing_files: - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - print(f"[{timestamp}] ℹ️ Skipping {len(existing_files)} existing files\n") - - # Инициализация состояния обработки - self.SizeFirst = STANDARD_RAW_SIZE - self.time_idx = np.arange(1, self.SizeFirst + 1) - self.k = 1 - self.SUM = 0.0 - self.B = None - self.meandr = None - self.signal = np.array([]) - self.signalView = np.array([]) - self.timeSignal = np.array([]) - - # История B_scan с привязкой к времени файла - self.B_scan_data = [] - self.B_scan_times = [] - self.B_scan_types = [] # RAW, SYNC_DET, FOURIER, GAP - self.last_file_time = None - - # Интервал между файлами (в миллисекундах) - задаётся пользователем - self.file_interval_ms = DEFAULT_FILE_INTERVAL_MS - self.time_gap_threshold_ms = self.file_interval_ms * GAP_THRESHOLD_MULTIPLIER - - # ✓ ИСПРАВЛЕНИЕ: Счётчик пропущенных кадров - self.gap_frames_count = 0 - - self.startPointTime = 0 - self.timestart = time.perf_counter() - self.timestart2 = None - self.FshiftS = None - - # Очередь для добавления колонок (без блокировок) - self.bscan_queue = queue.Queue() - - # Для потоковой отрисовки - self.update_lock = threading.Lock() - self.pending_update = False - self.pending_original_size = None - self.pending_data_type = None - - # Автопрокрутка по времени - self.auto_follow = tk.BooleanVar(value=True) - - # Флаг для отключения автопрокрутки при ручном выборе - self.manual_range_selection = False - - # Время опроса файлов (в миллисекундах) - self.file_poll_interval_ms = DEFAULT_FILE_POLL_INTERVAL_MS - - # Параметры слайдеров для B_scan - self.bscan_row_min = 0 - self.bscan_row_max = height - 1 - self.bscan_col_min = 0 - self.bscan_col_max = timePoint - 1 - - # Главный фрейм с двумя колонками - self.main_frame = ttk.Frame(self.root) - self.main_frame.pack(fill='both', expand=True, padx=3, pady=3) - - # Левая колонка: Графики (65%) - self.left_frame = ttk.Frame(self.main_frame) - self.left_frame.pack(side='left', fill='both', expand=True, padx=(0, 3)) - - # Правая колонка: Настройки (35%) - self.right_frame = ttk.Frame(self.main_frame, width=210) - self.right_frame.pack(side='right', fill='both', padx=(3, 0)) - self.right_frame.pack_propagate(False) - - # Инициализация графиков и настроек - self.init_plots_panel() - self.init_settings_panel() - - self.processed_count = 0 - self.skipped_count = 0 - - # ============================================================================ - # ИНИЦИАЛИЗАЦИЯ ИНТЕРФЕЙСА - # ============================================================================ - - def init_plots_panel(self): - """Инициализация панели с графиками (слева).""" - title = ttk.Label(self.left_frame, text="Графики", font=('Arial', 11, 'bold')) - title.pack() - - fig_frame = ttk.Frame(self.left_frame) - fig_frame.pack(fill='both', expand=True, padx=3, pady=3) - - self.fig = plt.Figure(figsize=(9, 6.5), dpi=100) - gs = self.fig.add_gridspec(2, 2, hspace=0.4, wspace=0.35) - - self.ax_raw = self.fig.add_subplot(gs[0, 0]) - self.ax_processed = self.fig.add_subplot(gs[0, 1]) - self.ax_fourier = self.fig.add_subplot(gs[1, 0]) - self.ax_bscan = self.fig.add_subplot(gs[1, 1]) - - self.canvas = FigureCanvasTkAgg(self.fig, master=fig_frame) - self.canvas.draw() - self.canvas.get_tk_widget().pack(fill='both', expand=True) - - self.draw_empty_plots() - - def init_settings_panel(self): - """Инициализация панели с настройками (справа).""" - title = ttk.Label(self.right_frame, text="⚙️ Настройки", font=('Arial', 10, 'bold')) - title.pack(pady=3, padx=3) - - # Canvas с прокруткой - self.settings_canvas = tk.Canvas(self.right_frame, bg='white', highlightthickness=0) - scrollbar = ttk.Scrollbar(self.right_frame, orient='vertical', command=self.settings_canvas.yview) - scrollable_frame = ttk.Frame(self.settings_canvas) - - scrollable_frame.bind( - "", - lambda e: self.settings_canvas.configure(scrollregion=self.settings_canvas.bbox("all")) - ) - - self.settings_canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") - self.settings_canvas.configure(yscrollcommand=scrollbar.set) - - self.settings_canvas.pack(side='left', fill='both', expand=True) - scrollbar.pack(side='right', fill='y') - - # Контроль интервала между файлами - interval_label = ttk.Label(scrollable_frame, text="📊 File Interval (ms):", - font=('Arial', 9, 'bold'), foreground='darkblue') - interval_label.pack(anchor='w', padx=3, pady=(5, 0)) - - interval_control_frame = ttk.Frame(scrollable_frame) - interval_control_frame.pack(fill='x', padx=3, pady=(0, 2)) - - self.interval_var = tk.IntVar(value=self.file_interval_ms) - interval_spinbox = ttk.Spinbox(interval_control_frame, from_=50, to=5000, - textvariable=self.interval_var, - width=6, command=self.on_file_interval_changed) - interval_spinbox.pack(side='left', padx=(0, 3)) - - self.interval_scale = ttk.Scale(interval_control_frame, from_=50, to=5000, orient='horizontal', - variable=self.interval_var, - command=self.on_interval_scale_changed) - self.interval_scale.pack(side='left', fill='x', expand=True, padx=(0, 3)) - - self.interval_label = ttk.Label(interval_control_frame, text=f"{self.file_interval_ms}ms", - font=('Arial', 8), foreground='darkblue', width=6, - relief='sunken', anchor='center') - self.interval_label.pack(side='left') - - interval_hint = ttk.Label(scrollable_frame, text="Диапазон: 50-5000 мс", - font=('Arial', 7), foreground='gray') - interval_hint.pack(anchor='w', padx=3, pady=(0, 3)) - - # Контроль времени опроса файлов - poll_label = ttk.Label(scrollable_frame, text="⏱️ Poll Interval (ms):", - font=('Arial', 9, 'bold'), foreground='darkred') - poll_label.pack(anchor='w', padx=3, pady=(5, 0)) - - poll_control_frame = ttk.Frame(scrollable_frame) - poll_control_frame.pack(fill='x', padx=3, pady=(0, 2)) - - self.poll_interval_var = tk.IntVar(value=self.file_poll_interval_ms) - poll_spinbox = ttk.Spinbox(poll_control_frame, from_=10, to=5000, - textvariable=self.poll_interval_var, - width=6, command=self.on_poll_interval_changed) - poll_spinbox.pack(side='left', padx=(0, 3)) - - self.poll_scale = ttk.Scale(poll_control_frame, from_=10, to=5000, orient='horizontal', - variable=self.poll_interval_var, - command=self.on_poll_scale_changed) - self.poll_scale.pack(side='left', fill='x', expand=True, padx=(0, 3)) - - self.poll_label = ttk.Label(poll_control_frame, text=f"{self.file_poll_interval_ms}ms", - font=('Arial', 8), foreground='darkred', width=6, - relief='sunken', anchor='center') - self.poll_label.pack(side='left') - - poll_hint = ttk.Label(scrollable_frame, text="Диапазон: 10-5000 мс", - font=('Arial', 7), foreground='gray') - poll_hint.pack(anchor='w', padx=3, pady=(0, 3)) - - ttk.Separator(scrollable_frame, orient='horizontal').pack(fill='x', padx=3, pady=3) - - # Информация о кодировке кадров - legend_label = ttk.Label(scrollable_frame, text="B-Scan Legend:", - font=('Arial', 9, 'bold'), foreground='darkgreen') - legend_label.pack(anchor='w', padx=3, pady=3) - - legend_text = ttk.Label(scrollable_frame, - text="■ Colors: DATA\n■ White: GAP (lost frame)\n■ Black: ZERO (missing)", - font=('Arial', 8), foreground='darkgreen', justify='left') - legend_text.pack(anchor='w', padx=3, pady=(0, 3)) - - ttk.Separator(scrollable_frame, orient='horizontal').pack(fill='x', padx=3, pady=3) - - # Информация о типах данных и размерах - info_label = ttk.Label(scrollable_frame, text=f"B-scan types:", - font=('Arial', 9, 'bold'), foreground='darkblue') - info_label.pack(anchor='w', padx=3, pady=3) - - info_text = ttk.Label(scrollable_frame, - text=f"RAW/SYNC_DET: {height}px\nFOURIER: {STANDARD_FOURIER_SIZE}px", - font=('Arial', 8), foreground='darkblue', justify='left') - info_text.pack(anchor='w', padx=3, pady=(0, 3)) - - ttk.Separator(scrollable_frame, orient='horizontal').pack(fill='x', padx=3, pady=2) - - # Row Min/Max для выбора диапазона отображения - row_min_label = ttk.Label(scrollable_frame, text="Row Min (display range):", font=('Arial', 8)) - row_min_label.pack(anchor='w', padx=3, pady=(3, 0)) - self.slider_row_min_var = tk.IntVar(value=0) - self.scale_row_min = ttk.Scale(scrollable_frame, from_=0, to=STANDARD_FOURIER_SIZE - 1, - orient='horizontal', variable=self.slider_row_min_var, - command=self.on_slider_changed) - self.scale_row_min.pack(fill='x', expand=True, padx=3, pady=(0, 1)) - self.label_row_min = ttk.Label(scrollable_frame, text="0", font=('Arial', 8), foreground='blue') - self.label_row_min.pack(anchor='e', padx=3, pady=(0, 3)) - - # Row Max - row_max_label = ttk.Label(scrollable_frame, text="Row Max (display range):", font=('Arial', 8)) - row_max_label.pack(anchor='w', padx=3, pady=(3, 0)) - self.slider_row_max_var = tk.IntVar(value=STANDARD_FOURIER_SIZE - 1) - self.scale_row_max = ttk.Scale(scrollable_frame, from_=0, to=STANDARD_FOURIER_SIZE - 1, - orient='horizontal', variable=self.slider_row_max_var, - command=self.on_slider_changed) - self.scale_row_max.pack(fill='x', expand=True, padx=3, pady=(0, 1)) - self.label_row_max = ttk.Label(scrollable_frame, text=str(STANDARD_FOURIER_SIZE - 1), font=('Arial', 8), - foreground='blue') - self.label_row_max.pack(anchor='e', padx=3, pady=(0, 3)) - - # Col Min - col_min_label = ttk.Label(scrollable_frame, text="Col Min:", font=('Arial', 8)) - col_min_label.pack(anchor='w', padx=3, pady=(3, 0)) - self.slider_col_min_var = tk.IntVar(value=0) - self.scale_col_min = ttk.Scale(scrollable_frame, from_=0, to=1000, - orient='horizontal', variable=self.slider_col_min_var, - command=self.on_col_slider_changed) - self.scale_col_min.pack(fill='x', expand=True, padx=3, pady=(0, 1)) - self.label_col_min = ttk.Label(scrollable_frame, text="0", font=('Arial', 8), foreground='blue') - self.label_col_min.pack(anchor='e', padx=3, pady=(0, 3)) - - # Col Max - col_max_label = ttk.Label(scrollable_frame, text="Col Max:", font=('Arial', 8)) - col_max_label.pack(anchor='w', padx=3, pady=(3, 0)) - self.slider_col_max_var = tk.IntVar(value=timePoint - 1) - self.scale_col_max = ttk.Scale(scrollable_frame, from_=0, to=1000, - orient='horizontal', variable=self.slider_col_max_var, - command=self.on_col_slider_changed) - self.scale_col_max.pack(fill='x', expand=True, padx=3, pady=(0, 1)) - self.label_col_max = ttk.Label(scrollable_frame, text=str(timePoint - 1), font=('Arial', 8), foreground='blue') - self.label_col_max.pack(anchor='e', padx=3, pady=(0, 3)) - - # Разделитель - ttk.Separator(scrollable_frame, orient='horizontal').pack(fill='x', padx=3, pady=3) - - # Кнопка автопрокрутки - follow_check = tk.Checkbutton(scrollable_frame, text="📌 Автопрокрутка", - variable=self.auto_follow, font=('Arial', 8), - bg='white', activebackground='white', activeforeground='black', - command=self.on_auto_follow_changed) - follow_check.pack(anchor='w', padx=3, pady=2) - - # Кнопки - button_frame = ttk.Frame(scrollable_frame) - button_frame.pack(fill='x', padx=3, pady=2) - - reset_btn = ttk.Button(button_frame, text='🔄 Сброс', command=self.on_reset_clicked) - reset_btn.pack(fill='x', pady=1) - - export_btn = ttk.Button(button_frame, text='💾 Экспорт', command=self.on_export_clicked) - export_btn.pack(fill='x', pady=1) - - # Разделитель - ttk.Separator(scrollable_frame, orient='horizontal').pack(fill='x', padx=3, pady=3) - - # Статус - status_label = ttk.Label(scrollable_frame, text="Статус:", font=('Arial', 9, 'bold')) - status_label.pack(anchor='w', padx=3) - - self.status_text = ttk.Label(scrollable_frame, text='Ожидание...', - font=('Arial', 7, 'italic'), foreground='blue', - wraplength=160, justify='left') - self.status_text.pack(anchor='w', padx=3, pady=2) - - # Разделитель - ttk.Separator(scrollable_frame, orient='horizontal').pack(fill='x', padx=3, pady=3) - - # Статистика - stats_label = ttk.Label(scrollable_frame, text="Статистика:", font=('Arial', 9, 'bold')) - stats_label.pack(anchor='w', padx=3) - - self.skipped_text = ttk.Label(scrollable_frame, text='Пропущено: 0', - font=('Arial', 8), foreground='orange') - self.skipped_text.pack(anchor='w', padx=3, pady=1) - - self.processed_text = ttk.Label(scrollable_frame, text='Обработано: 0', - font=('Arial', 8), foreground='green') - self.processed_text.pack(anchor='w', padx=3, pady=1) - - # ✓ ИСПРАВЛЕНИЕ: Счётчик пропущенных кадров - self.gap_frames_text = ttk.Label(scrollable_frame, text='Пропущено кадров: 0', - font=('Arial', 8), foreground='red') - self.gap_frames_text.pack(anchor='w', padx=3, pady=1) - - self.bscan_text = ttk.Label(scrollable_frame, text='B-scan: 0 columns', - font=('Arial', 8), foreground='purple') - self.bscan_text.pack(anchor='w', padx=3, pady=1) - - self.time_text = ttk.Label(scrollable_frame, text='Время: --:--:--', - font=('Arial', 8), foreground='green') - self.time_text.pack(anchor='w', padx=3, pady=1) - - def draw_empty_plots(self): - """Рисует пустые графики с подписями.""" - self.ax_raw.cla() - self.ax_raw.plot([], []) - self.ax_raw.set_xlabel('Такты', fontsize=9) - self.ax_raw.set_ylabel('Сигнал', fontsize=9) - self.ax_raw.set_title('Данные с АЦП', fontsize=10) - self.ax_raw.grid(True, alpha=0.3) - self.ax_raw.tick_params(labelsize=8) - - self.ax_processed.cla() - self.ax_processed.plot([], []) - self.ax_processed.set_xlabel('Частота', fontsize=9) - self.ax_processed.set_ylabel('Сигнал', fontsize=9) - self.ax_processed.set_title('Обработанные данные', fontsize=10) - self.ax_processed.grid(True, alpha=0.3) - self.ax_processed.tick_params(labelsize=8) - - self.ax_fourier.cla() - self.ax_fourier.plot([], []) - self.ax_fourier.set_title('Фурье образ', fontsize=10) - self.ax_fourier.grid(True, alpha=0.3) - self.ax_fourier.tick_params(labelsize=8) - - self.ax_bscan.cla() - self.ax_bscan.set_title('B-Scan (динамический)', fontsize=10) - self.ax_bscan.set_xlabel('Время →', fontsize=9) - self.ax_bscan.set_ylabel('Частота/Дистанция', fontsize=9) - self.ax_bscan.tick_params(labelsize=8) - - self.canvas.draw() - - # ============================================================================ - # CALLBACKS ИНТЕРФЕЙСА - # ============================================================================ - - def on_file_interval_changed(self): - """Callback при изменении интервала между файлами через Spinbox.""" - try: - new_interval = self.interval_var.get() - if 50 <= new_interval <= 5000: - self.file_interval_ms = new_interval - self.time_gap_threshold_ms = new_interval * GAP_THRESHOLD_MULTIPLIER - self.interval_label.config(text=f"{new_interval}ms") - self.interval_scale.set(new_interval) - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - print( - f"[{timestamp}] 📊 File interval changed to {new_interval}ms (gap threshold: {self.time_gap_threshold_ms:.0f}ms)") - else: - self.interval_var.set(self.file_interval_ms) - except: - self.interval_var.set(self.file_interval_ms) - - def on_interval_scale_changed(self, val): - """Callback при изменении интервала между файлами через Scale.""" - try: - new_interval = int(float(val)) - if 50 <= new_interval <= 5000: - self.file_interval_ms = new_interval - self.time_gap_threshold_ms = new_interval * GAP_THRESHOLD_MULTIPLIER - self.interval_var.set(new_interval) - self.interval_label.config(text=f"{new_interval}ms") - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - print( - f"[{timestamp}] 📊 File interval changed to {new_interval}ms (via slider, gap threshold: {self.time_gap_threshold_ms:.0f}ms)") - else: - self.interval_scale.set(self.file_interval_ms) - except: - self.interval_scale.set(self.file_interval_ms) - - def on_poll_interval_changed(self): - """Callback при изменении времени опроса файлов через Spinbox.""" - try: - new_interval = self.poll_interval_var.get() - if 10 <= new_interval <= 5000: - self.file_poll_interval_ms = new_interval - self.poll_label.config(text=f"{new_interval}ms") - self.poll_scale.set(new_interval) - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - print(f"[{timestamp}] ⏱️ Poll interval changed to {new_interval}ms") - else: - self.poll_interval_var.set(self.file_poll_interval_ms) - except: - self.poll_interval_var.set(self.file_poll_interval_ms) - - def on_poll_scale_changed(self, val): - """Callback при изменении времени опроса файлов через Scale.""" - try: - new_interval = int(float(val)) - if 10 <= new_interval <= 5000: - self.file_poll_interval_ms = new_interval - self.poll_interval_var.set(new_interval) - self.poll_label.config(text=f"{new_interval}ms") - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - print(f"[{timestamp}] ⏱️ Poll interval changed to {new_interval}ms (via slider)") - else: - self.poll_scale.set(self.file_poll_interval_ms) - except: - self.poll_scale.set(self.file_poll_interval_ms) - - def on_slider_changed(self, val=None): - """Callback при изменении слайдеров Row Min/Max.""" - self.bscan_row_min = self.slider_row_min_var.get() - self.bscan_row_max = self.slider_row_max_var.get() - - self.label_row_min.config(text=str(self.bscan_row_min)) - self.label_row_max.config(text=str(self.bscan_row_max)) - - self.redraw_bscan_immediate() - - def on_col_slider_changed(self, val=None): - """Callback для Col Min/Max - обновляет B_scan и перерисовывает.""" - self.bscan_col_min = self.slider_col_min_var.get() - self.bscan_col_max = self.slider_col_max_var.get() - - self.label_col_min.config(text=str(self.bscan_col_min)) - self.label_col_max.config(text=str(self.bscan_col_max)) - - # Отключаем автопрокрутку при ручном выборе - self.manual_range_selection = True - self.auto_follow.set(False) - - # СРАЗУ перерисовываем B_scan - self.redraw_bscan_immediate() - - def on_auto_follow_changed(self): - """Callback при изменении автопрокрутки.""" - if self.auto_follow.get(): - self.manual_range_selection = False - - def on_reset_clicked(self): - """Callback при нажатии кнопки Reset.""" - self.slider_row_min_var.set(0) - self.slider_row_max_var.set(STANDARD_FOURIER_SIZE - 1) - self.slider_col_min_var.set(0) - self.slider_col_max_var.set(min(timePoint - 1, max(0, len(self.B_scan_data) - 1))) - self.manual_range_selection = False - self.auto_follow.set(True) - - def on_export_clicked(self): - """Callback для экспорта графиков.""" - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")[:-3] - filename = f"export_{timestamp}.png" - self.fig.savefig(filename, dpi=150, bbox_inches='tight') - print(f"✓ Экспортировано: {filename}") - - def redraw_bscan_immediate(self): - """Немедленная перерисовка B_scan при изменении ползунков.""" - if len(self.B_scan_data) == 0: - return - - total_cols = len(self.B_scan_data) - - col_min = min(self.bscan_col_min, total_cols - 1) - col_max = min(self.bscan_col_max + 1, total_cols) - - # Проверяем корректность диапазона - if col_min >= col_max: - return - - row_min = self.bscan_row_min - row_max = self.bscan_row_max - - # Собираем данные для отображения - display_cols = [] - display_types = [] # Тип каждого столбца - for i in range(col_min, col_max): - col_data = self.B_scan_data[i] - col_type = self.B_scan_types[i] - # Берём диапазон по высоте - if len(col_data) > row_min: - row_end = min(row_max + 1, len(col_data)) - row_start = min(row_min, len(col_data) - 1) - if row_end > row_start: - display_cols.append(col_data[row_start:row_end]) - display_types.append(col_type) - - display_times = self.B_scan_times[col_min:col_max] - - # Перерисовываем B_scan - self.ax_bscan.cla() - if display_cols: - # Находим максимальную длину для создания матрицы - max_len = max(len(col) for col in display_cols) - - # Создаём матрицу, заполняя нулями если необходимо - display_data = np.zeros((max_len, len(display_cols))) - for i, col in enumerate(display_cols): - display_data[:len(col), i] = col - - # Проверяем столбцы на GAP - gap_mask = np.array([col_type == "GAP" for col_type in display_types]) - - # Применяем маску: GAP столбцы становятся белыми (все = max_val * 1.5) - max_val = display_data.max() - if max_val > 0: - for i, is_gap in enumerate(gap_mask): - if is_gap: - display_data[:, i] = max_val * 1.5 # Белый (выше максимума) - - im = self.ax_bscan.imshow(display_data, origin='lower', aspect='auto', cmap='viridis') - - time_labels = [t.strftime("%H:%M:%S.%f")[:-3] if t else "?" for t in display_times] - - if len(time_labels) > 1: - step = max(1, len(time_labels) // 10) - tick_positions = np.arange(0, len(time_labels), step) - tick_labels_subset = [time_labels[i] if i < len(time_labels) else "" for i in tick_positions] - self.ax_bscan.set_xticks(tick_positions) - self.ax_bscan.set_xticklabels(tick_labels_subset, rotation=45, ha='right', fontsize=7) - else: - self.ax_bscan.set_xticks([]) - - self.ax_bscan.set_title(f'B-Scan (Cols {col_min}-{col_max - 1})', fontsize=10) - self.ax_bscan.set_xlabel('Время →', fontsize=9) - self.ax_bscan.set_ylabel(f'Диапазон [{row_min}-{row_max}]', fontsize=9) - self.ax_bscan.tick_params(labelsize=8) - - # Сразу рисуем - self.canvas.draw() - - # ============================================================================ - # ОБРАБОТКА ДАННЫХ - # ============================================================================ - - def process_raw_data(self, A, original_size): - """Обработка сырых данных.""" - A_1d = A[:, 0] if A.ndim > 1 else A - A_resized = resize_1d_interpolate(A_1d, STANDARD_RAW_SIZE) - - if self.SizeFirst != STANDARD_RAW_SIZE: - self.SizeFirst = STANDARD_RAW_SIZE - self.time_idx = np.arange(1, self.SizeFirst + 1) - self.B = None - self.meandr = None - self.k = 1 - self.SUM = 0.0 - - if self.k < PeriodIntegrate: - if isinstance(self.SUM, float) and self.SUM == 0.0: - self.SUM = A_resized.astype(float) - else: - self.SUM = self.SUM + A_resized - - if self.k == 1: - self.B = np.zeros((self.SizeFirst, PeriodIntegrate + 2)) - self.B[:, 0] = A_resized - self.meandr = square(self.time_idx * np.pi) - self.timestart2 = time.perf_counter() - else: - self.B[:, self.k] = A_resized - - self.k += 1 - return False, None - - else: - last_col = PeriodIntegrate + 1 - self.B[:, last_col] = A_resized - self.B = np.roll(self.B, 1, axis=1) - - if PeriodIntegrate > 1: - meanB = np.sum(self.B[:, 1:PeriodIntegrate], axis=1) / PeriodIntegrate - else: - meanB = self.B[:, 0] - - mwanBmeandr = meanB * self.meandr - - signal_list = [] - signalView_list = [] - timeSignal_list = [] - - start = 0 - numberOfFreqChangeStart = 0 - for numberOfFreqChange in range(self.SizeFirst): - if (numberOfFreqChange - start) > pontInOneFqChange: - segment = mwanBmeandr[numberOfFreqChangeStart:numberOfFreqChange] - if segment.size > 0: - signal_list.append(np.sum(segment)) - signalView_list.append(np.mean(segment)) - timeSignal_list.append(numberOfFreqChange) - start = numberOfFreqChange - numberOfFreqChangeStart = numberOfFreqChange - - self.signal = np.array(signal_list, dtype=float) - self.signalView = np.array(signalView_list, dtype=float) - self.timeSignal = np.array(timeSignal_list, dtype=int) - - self.compute_fft() - - if self.FshiftS is not None: - center = len(self.FshiftS) // 2 - src_start = center + FFT0_delta - src_end = src_start + height - if src_end <= len(self.FshiftS): - bscan_col = self.FshiftS[src_start:src_end].copy() - return True, bscan_col - - return True, None - - def process_sync_det_data(self, A, original_size): - """Обработка данных синхронного детектирования.""" - A_1d = A[:, 0] if A.ndim > 1 else A - A_resized = resize_1d_interpolate(A_1d, STANDARD_SYNC_SIZE) - - self.signal = A_resized - self.signalView = self.signal * 0.1 - self.timeSignal = np.arange(len(self.signal)) - - self.compute_fft() - - if self.FshiftS is not None: - center = len(self.FshiftS) // 2 - src_start = center + FFT0_delta - src_end = src_start + height - if src_end <= len(self.FshiftS): - bscan_col = self.FshiftS[src_start:src_end].copy() - return True, bscan_col - - return True, None - - def process_fourier_data(self, A, original_size): - """Обработка FOURIER без интерполяции. Поддерживает несколько сегментов.""" - columns_to_add = [] - - # A может быть: list[np.ndarray] (из HEX) или numpy.ndarray - if isinstance(A, list): - for seg in A: - col = np.asarray(seg, dtype=float) - columns_to_add.append(col) - return True, columns_to_add - - if A.ndim == 1: - columns_to_add.append(A.astype(float)) - return True, columns_to_add - - # Если A двумерный: считаем колонками столбцы или строки — выбираем более длинное измерение как длину спектра - if A.ndim == 2: - rows, cols = A.shape - if rows >= cols: - for i in range(cols): - columns_to_add.append(A[:, i].astype(float)) - else: - for i in range(rows): - columns_to_add.append(A[i, :].astype(float)) - return True, columns_to_add - - return True, columns_to_add - - def add_bscan_column(self, data_col, current_time, data_type): - """Добавить колонку в B-скан (может быть разного размера).""" - - if self.last_file_time is None: - self.B_scan_data.append(data_col) - self.B_scan_times.append(current_time) - self.B_scan_types.append(data_type) - self.last_file_time = current_time - return - - # Используем переменный интервал между файлами - time_diff_ms = (current_time - self.last_file_time).total_seconds() * 1000 - - if time_diff_ms > self.time_gap_threshold_ms: - missing_count = int(round(time_diff_ms / self.file_interval_ms)) - 1 - - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - print( - f"[{timestamp}] ⚠️ Gap detected: {time_diff_ms:.1f}ms (missing ~{missing_count} columns, threshold: {self.time_gap_threshold_ms:.0f}ms)") - - # ✓ ИСПРАВЛЕНИЕ: Увеличиваем счётчик пропущенных кадров - self.gap_frames_count += missing_count - - last_size = len(self.B_scan_data[-1]) if self.B_scan_data else height - for i in range(missing_count): - zero_col = np.zeros(last_size) - self.B_scan_data.append(zero_col) - gap_time = self.last_file_time + timedelta(milliseconds=self.file_interval_ms * (i + 1)) - self.B_scan_times.append(gap_time) - # Отмечаем как GAP (пропущенный кадр) - self.B_scan_types.append("GAP") - - self.B_scan_data.append(data_col) - self.B_scan_times.append(current_time) - self.B_scan_types.append(data_type) - self.last_file_time = current_time - - def compute_fft(self): - """Вычисляем FFT спектр.""" - if self.signal.size > 0: - sig_cut = self.signal[:FQend] if len(self.signal) >= FQend else self.signal - sig_cut = np.sqrt(np.abs(sig_cut)) - F = np.fft.fft(sig_cut) - Fshift = np.abs(np.fft.fftshift(F)) - center = len(sig_cut) // 2 - if center < len(Fshift): - Fshift[max(center - 0, 0):min(center + 1, len(Fshift))] = 0 - self.FshiftS = gaussian_filter1d(Fshift, 5) - - # ============================================================================ - # ОБНОВЛЕНИЕ GUI - # ============================================================================ - - def schedule_update(self, original_size, data_type): - """Отложить обновление GUI.""" - with self.update_lock: - self.pending_original_size = original_size - self.pending_data_type = data_type - self.pending_update = True - - def do_pending_update(self): - """Обновляем GUI.""" - with self.update_lock: - if not self.pending_update: - return - - original_size = self.pending_original_size - data_type = self.pending_data_type - self.pending_update = False - - # Извлекаем все элементы из очереди - while not self.bscan_queue.empty(): - try: - bscan_col, file_time, data_type_col = self.bscan_queue.get_nowait() - self.add_bscan_column(bscan_col, file_time, data_type_col) - except queue.Empty: - break - - # Обновление графиков - self.ax_raw.cla() - if self.B is not None and self.meandr is not None: - self.ax_raw.plot(self.time_idx, np.abs(self.B[:, 0] if self.B.ndim > 1 else self.B), - label='B', linewidth=0.5, alpha=0.7) - if self.timeSignal.size > 0: - self.ax_raw.plot(self.timeSignal, np.abs(self.signalView), linewidth=0.5) - self.ax_raw.set_xlabel('Такты', fontsize=9) - self.ax_raw.set_ylabel('Сигнал', fontsize=9) - self.ax_raw.set_title('Данные с АЦП', fontsize=10) - self.ax_raw.grid(True, alpha=0.3) - self.ax_raw.tick_params(labelsize=8) - - self.ax_processed.cla() - if self.signal.size > 0: - ssS = self.signal.size - perpointFq = 10.67 / ssS - XSignal = 3 + (np.arange(1, ssS + 1) * perpointFq) - self.ax_processed.plot(XSignal, np.abs(self.signal), linewidth=1) - self.ax_processed.set_xlabel('Частота', fontsize=9) - self.ax_processed.set_ylabel('Сигнал', fontsize=9) - self.ax_processed.set_title('Обработанные данные', fontsize=10) - self.ax_processed.grid(True, alpha=0.3) - self.ax_processed.tick_params(labelsize=8) - - self.ax_fourier.cla() - if self.FshiftS is not None: - self.ax_fourier.plot(self.FshiftS, linewidth=1) - self.ax_fourier.set_xlim([0, min(FQend, len(self.FshiftS))]) - self.ax_fourier.set_title('Фурье образ', fontsize=10) - self.ax_fourier.grid(True, alpha=0.3) - self.ax_fourier.tick_params(labelsize=8) - - # Отрисовываем B_scan с автопрокруткой - if len(self.B_scan_data) > 0: - total_cols = len(self.B_scan_data) - - if self.auto_follow.get() and not self.manual_range_selection: - self.bscan_col_min = max(0, total_cols - timePoint) - self.bscan_col_max = total_cols - 1 - self.slider_col_min_var.set(self.bscan_col_min) - self.slider_col_max_var.set(self.bscan_col_max) - - self.redraw_bscan_immediate() - - total_cols = len(self.B_scan_data) - self.bscan_text.config(text=f'B-scan: {total_cols} columns') - - # Обновляем статус - status_msg = f"Тип: {data_type}\nИсх: {original_size}" - self.status_text.config(text=status_msg) - - self.skipped_text.config(text=f'Пропущено: {self.skipped_count}') - self.processed_text.config(text=f'Обработано: {self.processed_count}') - - # ✓ ИСПРАВЛЕНИЕ: Обновляем счётчик пропущенных кадров - self.gap_frames_text.config(text=f'Пропущено кадров: {self.gap_frames_count}') - - if self.B_scan_times: - last_time = self.B_scan_times[-1].strftime("%H:%M:%S.%f")[:-3] - self.time_text.config(text=f'Время: {last_time}') - - # Обновляем диапазоны слайдеров - if len(self.B_scan_data) > 0: - self.scale_col_max.config(to=len(self.B_scan_data) - 1) - if not self.auto_follow.get(): - self.slider_col_max_var.set(min(self.slider_col_max_var.get(), len(self.B_scan_data) - 1)) - - # Принудительный draw() - self.canvas.draw() - - # ============================================================================ - # ОСНОВНОЙ ЦИКЛ ОБРАБОТКИ ФАЙЛОВ - # ============================================================================ - - def process_file_thread(self, fname, data_type, A, original_size): - """Обработка файла в отдельном потоке.""" - try: - file_time = get_file_time_with_milliseconds(fname) - - bscan_col = None - add_to_bscan = False - - if data_type == DATA_TYPE_RAW: - # Может прийти список сегментов (HEX с FE) - if isinstance(A, list): - for i, seg in enumerate(A): - add_to_bscan, bscan_col = self.process_raw_data(np.asarray(seg), len(seg)) - if add_to_bscan and bscan_col is not None: - col_time = file_time + timedelta(milliseconds=i * 10) - self.bscan_queue.put((bscan_col, col_time, DATA_TYPE_RAW)) - add_to_bscan, bscan_col = False, None - else: - add_to_bscan, bscan_col = self.process_raw_data(A, original_size) - elif data_type == DATA_TYPE_SYNC_DET: - if isinstance(A, list): - for i, seg in enumerate(A): - add_to_bscan, bscan_col = self.process_sync_det_data(np.asarray(seg), len(seg)) - if add_to_bscan and bscan_col is not None: - col_time = file_time + timedelta(milliseconds=i * 10) - self.bscan_queue.put((bscan_col, col_time, DATA_TYPE_SYNC_DET)) - add_to_bscan, bscan_col = False, None - else: - add_to_bscan, bscan_col = self.process_sync_det_data(A, original_size) - elif data_type == DATA_TYPE_FOURIER: - add_to_bscan, columns = self.process_fourier_data(A, original_size) - if add_to_bscan and columns: - for i, col in enumerate(columns): - col_time = file_time + timedelta(milliseconds=i * 10) - self.bscan_queue.put((col, col_time, DATA_TYPE_FOURIER)) - bscan_col = None - - if add_to_bscan and bscan_col is not None and data_type != DATA_TYPE_FOURIER: - self.bscan_queue.put((bscan_col, file_time, data_type)) - - self.schedule_update(original_size, data_type) - self.processed_count += 1 - - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - file_time_str = file_time.strftime("%H:%M:%S.%f")[:-3] - print(f"[{timestamp}] ✓ {fname} ({data_type}) [FileTime: {file_time_str}]") - - except Exception as e: - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - print(f"[{timestamp}] ✗ Error: {str(e)[:50]}") - - def run(self): - """Основной цикл.""" - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] - print("=" * 80) - print(f" Radar Data Analyzer - Started: {timestamp}") - print(f" Waiting for .txt files in: {self.data_dir}") - print(f" RAW/SYNC_DET A-scan height: {height} pixels") - print(f" FOURIER A-scan size: {STANDARD_FOURIER_SIZE} pixels (N/2)") - print(f" B-scan: Time-synchronized (milliseconds), display range selectable") - print(f" File interval: {self.file_interval_ms}ms (user adjustable)") - print(f" Gap threshold: {self.time_gap_threshold_ms:.0f}ms ({GAP_THRESHOLD_MULTIPLIER}x interval)") - print(f" Poll interval: {self.file_poll_interval_ms}ms (user adjustable)") - print(f" FOURIER_MODE: {FOURIER_MODE}") - print(f" B-Scan display: GAP (lost frames) shown as WHITE") - print("=" * 80 + "\n") - - self.process_files() - - def process_files(self): - """Обработка файлов в цикле.""" - files = sorted([f for f in os.listdir() if f.endswith('.csv') or - f.endswith('.txt1') or f.endswith('.txt2') or f.endswith('.csv')]) - - new_files = [f for f in files if f not in self.processed_files] - print("new files:", new_files, files) - - for fname in new_files: - time_start = time.perf_counter() - - try: - data_type, A = load_data_with_type(fname) - # Поддержка списка сегментов (HEX с FE) - if isinstance(A, list): - original_size = len(A[0]) if len(A) > 0 else 0 - elif isinstance(A, np.ndarray): - original_size = A.shape[0] - else: - original_size = 0 - - # Если после парсинга данных нет — пропускаем файл - if (isinstance(A, list) and len(A) == 0) or (isinstance(A, np.ndarray) and A.size == 0): - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - print(f"[{timestamp}] ⏭️ SKIP {fname} (no data parsed)") - self.skipped_count += 1 - self.processed_files.add(fname) - continue - - elapsed_time_ms = (time.perf_counter() - time_start) * 1000 - - if elapsed_time_ms > MAX_PROCESSING_TIME_MS: - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - print(f"[{timestamp}] ⏭️ SKIP {fname} (load time: {elapsed_time_ms:.1f}ms)") - self.skipped_count += 1 - else: - thread = threading.Thread( - target=self.process_file_thread, - args=(fname, data_type, A, original_size), - daemon=True - ) - thread.start() - - self.processed_files.add(fname) - - except Exception as e: - timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] - print(f"[{timestamp}] ✗ Load error: {str(e)[:50]}") - self.processed_files.add(fname) - - self.do_pending_update() - # Используем переменное время опроса - self.root.after(self.file_poll_interval_ms, self.process_files) - - -# ================================================================================ -# ТОЧКА ВХОДА -# ================================================================================ - -if __name__ == "__main__": - root = tk.Tk() - app = DataAnalyzerApp(root) - app.run() - - try: - root.mainloop() - except KeyboardInterrupt: - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] - print("\n" + "=" * 80) - print(f" Program stopped by user: {timestamp}") - print("=" * 80) +#!/usr/bin/python3 + +import os +import time +import numpy as np +import tkinter as tk +from tkinter import ttk +import matplotlib.pyplot as plt +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from scipy.signal import square +from scipy.ndimage import gaussian_filter1d +from scipy.interpolate import interp1d +from datetime import datetime, timedelta +import threading +import queue +from sys import argv +from collections import deque + +# ================================================================================ +# ПАРАМЕТРЫ И КОНСТАНТЫ +# ================================================================================ +#data_dir = r"D:\data" +data_dir = "/home/awe/Documents/E502_ADC_BF_PC_companion/tmp" +PeriodIntegrate = 2 +pontInOneFqChange = 86 + +# Высота B-скана для RAW/SYNC_DET обработки +height = 60 + +timePoint = 100 +FQend = 512 +FFT0_delta = 5 + +STANDARD_RAW_SIZE = 64000 +STANDARD_SYNC_SIZE = 1000 + +# FOURIER размер = SYNC_DET_SIZE // 2 (положительные частоты) +STANDARD_FOURIER_SIZE = STANDARD_SYNC_SIZE // 2 # 1000 // 2 = 500 + +STANDARD_FOURIER_ROWS = 60 +STANDARD_FOURIER_COLS = 100 + +MAX_PROCESSING_TIME_MS = 250 +FILES_STORED_N_MAX = 100 + +# Минимально допустимое число точек F0 для принятия данных +MIN_F0_POINTS = 100 + +# Усреднение B-scan по времени (фон) +BG_AVG_WINDOW_SEC = 5.0 +BG_SUBTRACT_ALPHA = 1.0 +BG_SUBTRACT_ENABLED = True +# Метод расчёта фона: 'median' или 'mean' +BG_BASELINE_METHOD = 'median' + +# ПЕРЕЧИСЛЕНИЕ ТИПОВ ДАННЫХ +DATA_TYPE_RAW = "RAW" +DATA_TYPE_SYNC_DET = "SYNC_DET" +DATA_TYPE_FOURIER = "FOURIER" +DATA_TYPE_HEX = "HEX" + +# Режим обработки FOURIER файлов +FOURIER_MODE = 'collapse_mean' +FOURIER_SUBSAMPLE_K = 10 + +# Начальный интервал между файлами (в миллисекундах) +DEFAULT_FILE_INTERVAL_MS = 300 # 300 мс + +# Коэффициент для определения разрыва (1.5x интервала) +GAP_THRESHOLD_MULTIPLIER = 1.5 + +# Начальное время опроса файлов (в миллисекундах) +DEFAULT_FILE_POLL_INTERVAL_MS = 40 # 100 мс + +# Игнорировать пропущенные данные (не добавлять GAP-колонки) +IGNORE_LOST = True + +# Игнорировать спектры из F4 и рассчитывать FFT из временных данных +IGNORE_F4_FFT_DATA = True + + +# ================================================================================ +# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ +# ================================================================================ + +def detect_data_type(first_line): + """Определяет тип данных по первой строке файла. + + Логика: если первая строка начинается с ключевого слова RAW/SYNC_DET/FOURIER/FFT, + считаем соответствующий тип. Иначе — HEX. + """ + try: + up = first_line.strip().upper() + if up.startswith('RAW'): + return DATA_TYPE_RAW + if up.startswith('SYNC_DET') or up.startswith('SYNC DET'): + return DATA_TYPE_SYNC_DET + if up.startswith('FOURIER') or up.startswith('FFT'): + return DATA_TYPE_FOURIER + return DATA_TYPE_HEX + except Exception: + return DATA_TYPE_HEX + + +def resize_1d_interpolate(data, target_size): + """Ресайзит одномерный массив с использованием линейной интерполяции.""" + if len(data) == target_size: + return data + + old_indices = np.linspace(0, 1, len(data)) + new_indices = np.linspace(0, 1, target_size) + + f = interp1d(old_indices, data, kind='linear', fill_value='extrapolate') + return f(new_indices) + + +def resize_2d_interpolate(data, target_rows, target_cols): + """Ресайзит двумерный массив на target_rows x target_cols с интерполяцией.""" + rows, cols = data.shape + + if rows == target_rows and cols == target_cols: + return data + + old_row_indices = np.linspace(0, 1, rows) + new_row_indices = np.linspace(0, 1, target_rows) + f_rows = interp1d(old_row_indices, data, axis=0, kind='linear', fill_value='extrapolate') + data_resampled_rows = f_rows(new_row_indices) + + old_col_indices = np.linspace(0, 1, cols) + new_col_indices = np.linspace(0, 1, target_cols) + f_cols = interp1d(old_col_indices, data_resampled_rows, axis=1, kind='linear', fill_value='extrapolate') + data_resampled = f_cols(new_col_indices) + + return data_resampled + + +def is_all_zero(arr: np.ndarray, eps: float = 0.0) -> bool: + """Возвращает True, если все значения массива близки к нулю.""" + try: + return not np.any(np.abs(arr) > eps) + except Exception: + return False + + + +def BF_fft_postprocessor(spectrum: np.ndarray) -> np.ndarray: + """Болванка постобработки FFT-данных, полученных из файла (F4). + + Принимает 1D массив амплитуд спектра и возвращает преобразованный массив + той же длины. По умолчанию — тождественное преобразование. + """ + + + + spec_L = len(spectrum) + spectrum_lower = spectrum[:spec_L//2] + spectrum_higher = spectrum[spec_L//2:] + + spectrum[:spec_L//2] = spectrum_higher + spectrum[spec_L//2:] = spectrum_lower[::-1] + + try: + print ("spectrum processed") + return np.asarray(spectrum_tmp, dtype=float) + except Exception: + return spectrum + + +''' +def BF_fft_postprocessor(spectrum: np.ndarray) -> np.ndarray: + """Болванка постобработки FFT-данных, полученных из файла (F4). + + Принимает 1D массив амплитуд спектра и возвращает преобразованный массив + той же длины. По умолчанию — тождественное преобразование. + """ + try: + return np.asarray(spectrum, dtype=float) + except Exception: + return spectrum + +''' + + +def load_data_with_type(filename): + """Загружает данные и определяет их тип по первой строке.""" + with open(filename, 'r') as f: + first_line = f.readline() + + detected_type = detect_data_type(first_line) + + if detected_type != DATA_TYPE_HEX: + try: + data = np.loadtxt(filename, skiprows=1) + except: + data = np.loadtxt(filename) + return detected_type, data + + # HEX формат: строки вида 0xAABBBBBB, где AA — тип, BBBBBB — int24_t + return parse_hex_file(filename) + + +def parse_hex_file(filename): + """Парсит HEX формат с разделением по FE и мапит к RAW/SYNC_DET/FOURIER. + + Возвращает (data_type, data), где data может быть: + - numpy.ndarray (1D) для одного сегмента + - list[numpy.ndarray] для нескольких сегментов (используется для FOURIER, а также RAW/SYNC_DET) + """ + + def to_int24(v): + x = int(v, 16) + if x & 0x800000: + x -= 0x1000000 + return float(x) + + # Текущий накапливаемый сегмент + cur = {"D0": [], "F0": [], "F1": [], "F2": [], "F3": [], "F4": []} + # Списки сегментов по типам данных + seg_raw = [] + seg_sync = [] + seg_fourier = [] + + def _is_all_zero_local(arr_like) -> bool: + try: + arr = np.asarray(arr_like, dtype=float) + return not np.any(np.abs(arr) > 0) + except Exception: + return False + + def _f0_is_valid_local(f0_like) -> bool: + try: + arr = np.asarray(f0_like, dtype=float) + return arr.size >= MIN_F0_POINTS and (not _is_all_zero_local(arr)) + except Exception: + return False + + def finalize_segment(): + nonlocal cur + # Приоритет выбора сегмента: + # 1) Если есть F4 — используем как FOURIER; F0 (если есть) передаём для отображения без расчёта FFT + # 2) Иначе F1+F2 → амплитуда + # 3) Иначе F3 (sqrt) + # 4) Иначе F0 как SYNC_DET + # 5) Иначе D0 как RAW + if cur["F4"] and not IGNORE_F4_FFT_DATA: + # FOURIER данные получены напрямую из файла (F4) + col = np.asarray(cur["F4"], dtype=float) + col = BF_fft_postprocessor(col) + if cur["F0"] and _f0_is_valid_local(cur["F0"]): + # Сохраняем F0 рядом с F4 для отображения (без расчёта FFT) + f0 = np.asarray(cur["F0"], dtype=float) + seg_fourier.append((col, f0)) + else: + seg_fourier.append(col) + elif cur["F1"] and cur["F2"] and len(cur["F1"]) == len(cur["F2"]): + re = np.asarray(cur["F1"], dtype=float) + im = np.asarray(cur["F2"], dtype=float) + seg_fourier.append(np.sqrt(re * re + im * im)) + elif cur["F3"]: + arr = np.asarray(cur["F3"], dtype=float) + seg_fourier.append(np.sqrt(np.maximum(0.0, arr))) + elif cur["F0"]: + if _f0_is_valid_local(cur["F0"]): + seg_sync.append(np.asarray(cur["F0"], dtype=float)) + elif cur["D0"]: + seg_raw.append(np.asarray(cur["D0"], dtype=float)) + # Сброс + cur = {"D0": [], "F0": [], "F1": [], "F2": [], "F3": [], "F4": []} + + with open(filename, 'r') as f: + for line in f: + s = line.strip() + if not s: + continue + # Требование: учитывать только строки, начинающиеся с 0x/0X + if not (s.startswith('0x') or s.startswith('0X')): + continue + h = s[2:] + h = ''.join(ch for ch in h if ch in '0123456789abcdefABCDEF') + if len(h) < 2: + continue + t_byte = h[:2].upper() + + # FE — завершить текущий сегмент + if t_byte == 'FE': + finalize_segment() + continue + + # E0..E9 — игнор + if t_byte.startswith('E') and len(t_byte) == 2 and t_byte[1] in '0123456789': + continue + + # 00 — цифровые биты, пока пропускаем + if t_byte == '00': + continue + + if len(h) < 8: + continue + # Значение 24 бита + val_hex = h[2:8] + try: + value = to_int24(val_hex) + except Exception: + continue + + if t_byte == 'D0': + cur['D0'].append(value) + elif t_byte == 'F0': + cur['F0'].append(value) + elif t_byte == 'F1': + cur['F1'].append(value) + elif t_byte == 'F2': + cur['F2'].append(value) + elif t_byte == 'F3': + cur['F3'].append(value) + elif t_byte == 'F4': + cur['F4'].append(value) + else: + # Неизвестные — пропускаем + continue + + # Финализируем хвост + finalize_segment() + + if seg_fourier: + return DATA_TYPE_FOURIER, seg_fourier + if seg_sync: + # Если несколько, вернём список сегментов + return DATA_TYPE_SYNC_DET, seg_sync if len(seg_sync) > 1 else seg_sync[0] + if seg_raw: + return DATA_TYPE_RAW, seg_raw if len(seg_raw) > 1 else seg_raw[0] + + return DATA_TYPE_RAW, np.asarray([], dtype=float) + + +def get_file_time_with_milliseconds(filename): + """Получает время файла с миллисекундами из имени или mtime.""" + full_path = os.path.join(os.getcwd(), filename) + + milliseconds = 0 + parts = filename.split('_') + if len(parts) >= 4: + try: + last_part = parts[-1].split('.')[0] + if last_part.isdigit() and len(last_part) <= 3: + milliseconds = int(last_part) + except: + pass + + file_mtime = os.path.getmtime(full_path) + file_time = datetime.fromtimestamp(file_mtime) + + if milliseconds > 0: + file_time = file_time.replace(microsecond=milliseconds * 1000) + else: + now = datetime.now() + file_time = file_time.replace(microsecond=now.microsecond) + + return file_time + + +# ================================================================================ +# КЛАСС ПРИЛОЖЕНИЯ +# ================================================================================ + +class DataAnalyzerApp: + def __init__(self, root): + self.root = root + self.root.title("Radar Data Analyzer (Time Synchronized - Queue Based)") + # Уменьшено, чтобы помещалось на экране 1024x768 + self.root.geometry("1000x720") + + self.data_dir = data_dir + os.makedirs(self.data_dir, exist_ok=True) + os.chdir(self.data_dir) + + # Настройка: игнорировать пропуски кадров + self.ignore_lost = IGNORE_LOST + + # Инициализируем с существующими файлами + existing_files = sorted([ + f for f in os.listdir() + if f.lower().endswith(('.txt', '.txt1', '.txt2', '.csv')) + ]) + self.processed_files = set(existing_files) + + if existing_files: + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print(f"[{timestamp}] ℹ️ Skipping {len(existing_files)} existing files\n") + + # Инициализация состояния обработки + self.SizeFirst = STANDARD_RAW_SIZE + self.time_idx = np.arange(1, self.SizeFirst + 1) + self.k = 1 + self.SUM = 0.0 + self.B = None + self.meandr = None + self.signal = np.array([]) + self.signalView = np.array([]) + self.timeSignal = np.array([]) + + # История B_scan с привязкой к времени файла + self.B_scan_data = [] + self.B_scan_times = [] + self.B_scan_types = [] # RAW, SYNC_DET, FOURIER, GAP + self.last_file_time = None + + # Интервал между файлами (в миллисекундах) - задаётся пользователем + self.file_interval_ms = DEFAULT_FILE_INTERVAL_MS + self.time_gap_threshold_ms = self.file_interval_ms * GAP_THRESHOLD_MULTIPLIER + + # ✓ ИСПРАВЛЕНИЕ: Счётчик пропущенных кадров + self.gap_frames_count = 0 + + self.startPointTime = 0 + self.timestart = time.perf_counter() + self.timestart2 = None + self.FshiftS = None + + # Очередь для добавления колонок (без блокировок) + self.bscan_queue = queue.Queue() + + # Для потоковой отрисовки + self.update_lock = threading.Lock() + self.pending_update = False + self.pending_original_size = None + self.pending_data_type = None + + # Автопрокрутка по времени + self.auto_follow = tk.BooleanVar(value=True) + + # Флаг для отключения автопрокрутки при ручном выборе + self.manual_range_selection = False + + # Время опроса файлов (в миллисекундах) + self.file_poll_interval_ms = DEFAULT_FILE_POLL_INTERVAL_MS + + # Параметры слайдеров для B_scan + self.bscan_row_min = 0 + self.bscan_row_max = height - 1 + self.bscan_col_min = 0 + self.bscan_col_max = timePoint - 1 + + # Главный фрейм с двумя колонками + self.main_frame = ttk.Frame(self.root) + self.main_frame.pack(fill='both', expand=True, padx=3, pady=3) + + # Левая колонка: Графики (65%) + self.left_frame = ttk.Frame(self.main_frame) + self.left_frame.pack(side='left', fill='both', expand=True, padx=(0, 3)) + + # Правая колонка: Настройки (35%) + self.right_frame = ttk.Frame(self.main_frame, width=210) + self.right_frame.pack(side='right', fill='both', padx=(3, 0)) + self.right_frame.pack_propagate(False) + + # Инициализация графиков и настроек + self.init_plots_panel() + self.init_settings_panel() + + self.processed_count = 0 + self.skipped_count = 0 + + # Буфер последних колонок для фонового усреднения B-scan + self.bscan_buffer = deque() # элементов: (np.ndarray col, datetime t, str type) + self.bg_avg_window_sec = BG_AVG_WINDOW_SEC + self.bg_subtract_alpha = BG_SUBTRACT_ALPHA + self.bg_subtract_enabled = BG_SUBTRACT_ENABLED + self.bg_method = BG_BASELINE_METHOD + + # ============================================================================ + # ИНИЦИАЛИЗАЦИЯ ИНТЕРФЕЙСА + # ============================================================================ + + def init_plots_panel(self): + """Инициализация панели с графиками (слева).""" + title = ttk.Label(self.left_frame, text="Графики", font=('Arial', 11, 'bold')) + title.pack() + + fig_frame = ttk.Frame(self.left_frame) + fig_frame.pack(fill='both', expand=True, padx=3, pady=3) + + # Уменьшен стартовый размер фигуры, чтобы влезать в 1000x720 + self.fig = plt.Figure(figsize=(7.5, 6.0), dpi=100) + gs = self.fig.add_gridspec(2, 2, hspace=0.4, wspace=0.35) + + self.ax_raw = self.fig.add_subplot(gs[0, 0]) + self.ax_processed = self.fig.add_subplot(gs[0, 1]) + self.ax_fourier = self.fig.add_subplot(gs[1, 0]) + self.ax_bscan = self.fig.add_subplot(gs[1, 1]) + + self.canvas = FigureCanvasTkAgg(self.fig, master=fig_frame) + self.canvas.draw() + self.canvas.get_tk_widget().pack(fill='both', expand=True) + + self.draw_empty_plots() + + def init_settings_panel(self): + """Инициализация панели с настройками (справа).""" + title = ttk.Label(self.right_frame, text="⚙️ Настройки", font=('Arial', 10, 'bold')) + title.pack(pady=3, padx=3) + + # Canvas с прокруткой + self.settings_canvas = tk.Canvas(self.right_frame, bg='white', highlightthickness=0) + scrollbar = ttk.Scrollbar(self.right_frame, orient='vertical', command=self.settings_canvas.yview) + scrollable_frame = ttk.Frame(self.settings_canvas) + + scrollable_frame.bind( + "", + lambda e: self.settings_canvas.configure(scrollregion=self.settings_canvas.bbox("all")) + ) + + self.settings_canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") + self.settings_canvas.configure(yscrollcommand=scrollbar.set) + + self.settings_canvas.pack(side='left', fill='both', expand=True) + scrollbar.pack(side='right', fill='y') + + # Контроль интервала между файлами + interval_label = ttk.Label(scrollable_frame, text="📊 File Interval (ms):", + font=('Arial', 9, 'bold'), foreground='darkblue') + interval_label.pack(anchor='w', padx=3, pady=(5, 0)) + + interval_control_frame = ttk.Frame(scrollable_frame) + interval_control_frame.pack(fill='x', padx=3, pady=(0, 2)) + + self.interval_var = tk.IntVar(value=self.file_interval_ms) + interval_spinbox = ttk.Spinbox(interval_control_frame, from_=50, to=5000, + textvariable=self.interval_var, + width=6, command=self.on_file_interval_changed) + interval_spinbox.pack(side='left', padx=(0, 3)) + + self.interval_scale = ttk.Scale(interval_control_frame, from_=50, to=5000, orient='horizontal', + variable=self.interval_var, + command=self.on_interval_scale_changed) + self.interval_scale.pack(side='left', fill='x', expand=True, padx=(0, 3)) + + self.interval_label = ttk.Label(interval_control_frame, text=f"{self.file_interval_ms}ms", + font=('Arial', 8), foreground='darkblue', width=6, + relief='sunken', anchor='center') + self.interval_label.pack(side='left') + + interval_hint = ttk.Label(scrollable_frame, text="Диапазон: 50-5000 мс", + font=('Arial', 7), foreground='gray') + interval_hint.pack(anchor='w', padx=3, pady=(0, 3)) + + # Контроль времени опроса файлов + poll_label = ttk.Label(scrollable_frame, text="⏱️ Poll Interval (ms):", + font=('Arial', 9, 'bold'), foreground='darkred') + poll_label.pack(anchor='w', padx=3, pady=(5, 0)) + + poll_control_frame = ttk.Frame(scrollable_frame) + poll_control_frame.pack(fill='x', padx=3, pady=(0, 2)) + + self.poll_interval_var = tk.IntVar(value=self.file_poll_interval_ms) + poll_spinbox = ttk.Spinbox(poll_control_frame, from_=10, to=5000, + textvariable=self.poll_interval_var, + width=6, command=self.on_poll_interval_changed) + poll_spinbox.pack(side='left', padx=(0, 3)) + + self.poll_scale = ttk.Scale(poll_control_frame, from_=10, to=5000, orient='horizontal', + variable=self.poll_interval_var, + command=self.on_poll_scale_changed) + self.poll_scale.pack(side='left', fill='x', expand=True, padx=(0, 3)) + + self.poll_label = ttk.Label(poll_control_frame, text=f"{self.file_poll_interval_ms}ms", + font=('Arial', 8), foreground='darkred', width=6, + relief='sunken', anchor='center') + self.poll_label.pack(side='left') + + poll_hint = ttk.Label(scrollable_frame, text="Диапазон: 10-5000 мс", + font=('Arial', 7), foreground='gray') + poll_hint.pack(anchor='w', padx=3, pady=(0, 3)) + + ttk.Separator(scrollable_frame, orient='horizontal').pack(fill='x', padx=3, pady=3) + + # Информация о кодировке кадров + legend_label = ttk.Label(scrollable_frame, text="B-Scan Legend:", + font=('Arial', 9, 'bold'), foreground='darkgreen') + legend_label.pack(anchor='w', padx=3, pady=3) + + legend_text = ttk.Label(scrollable_frame, + text="■ Colors: DATA\n■ White: GAP (lost frame)\n■ Black: ZERO (missing)", + font=('Arial', 8), foreground='darkgreen', justify='left') + legend_text.pack(anchor='w', padx=3, pady=(0, 3)) + + ttk.Separator(scrollable_frame, orient='horizontal').pack(fill='x', padx=3, pady=3) + + # Информация о типах данных и размерах + info_label = ttk.Label(scrollable_frame, text=f"B-scan types:", + font=('Arial', 9, 'bold'), foreground='darkblue') + info_label.pack(anchor='w', padx=3, pady=3) + + info_text = ttk.Label(scrollable_frame, + text=f"RAW/SYNC_DET: {height}px\nFOURIER: {STANDARD_FOURIER_SIZE}px", + font=('Arial', 8), foreground='darkblue', justify='left') + info_text.pack(anchor='w', padx=3, pady=(0, 3)) + + ttk.Separator(scrollable_frame, orient='horizontal').pack(fill='x', padx=3, pady=2) + + # Row Min/Max для выбора диапазона отображения + row_min_label = ttk.Label(scrollable_frame, text="Row Min (display range):", font=('Arial', 8)) + row_min_label.pack(anchor='w', padx=3, pady=(3, 0)) + self.slider_row_min_var = tk.IntVar(value=0) + self.scale_row_min = ttk.Scale(scrollable_frame, from_=0, to=STANDARD_FOURIER_SIZE - 1, + orient='horizontal', variable=self.slider_row_min_var, + command=self.on_slider_changed) + self.scale_row_min.pack(fill='x', expand=True, padx=3, pady=(0, 1)) + self.label_row_min = ttk.Label(scrollable_frame, text="0", font=('Arial', 8), foreground='blue') + self.label_row_min.pack(anchor='e', padx=3, pady=(0, 3)) + + # Row Max + row_max_label = ttk.Label(scrollable_frame, text="Row Max (display range):", font=('Arial', 8)) + row_max_label.pack(anchor='w', padx=3, pady=(3, 0)) + self.slider_row_max_var = tk.IntVar(value=STANDARD_FOURIER_SIZE - 1) + self.scale_row_max = ttk.Scale(scrollable_frame, from_=0, to=STANDARD_FOURIER_SIZE - 1, + orient='horizontal', variable=self.slider_row_max_var, + command=self.on_slider_changed) + self.scale_row_max.pack(fill='x', expand=True, padx=3, pady=(0, 1)) + self.label_row_max = ttk.Label(scrollable_frame, text=str(STANDARD_FOURIER_SIZE - 1), font=('Arial', 8), + foreground='blue') + self.label_row_max.pack(anchor='e', padx=3, pady=(0, 3)) + + # Col Min + col_min_label = ttk.Label(scrollable_frame, text="Col Min:", font=('Arial', 8)) + col_min_label.pack(anchor='w', padx=3, pady=(3, 0)) + self.slider_col_min_var = tk.IntVar(value=0) + self.scale_col_min = ttk.Scale(scrollable_frame, from_=0, to=1000, + orient='horizontal', variable=self.slider_col_min_var, + command=self.on_col_slider_changed) + self.scale_col_min.pack(fill='x', expand=True, padx=3, pady=(0, 1)) + self.label_col_min = ttk.Label(scrollable_frame, text="0", font=('Arial', 8), foreground='blue') + self.label_col_min.pack(anchor='e', padx=3, pady=(0, 3)) + + # Col Max + col_max_label = ttk.Label(scrollable_frame, text="Col Max:", font=('Arial', 8)) + col_max_label.pack(anchor='w', padx=3, pady=(3, 0)) + self.slider_col_max_var = tk.IntVar(value=timePoint - 1) + self.scale_col_max = ttk.Scale(scrollable_frame, from_=0, to=1000, + orient='horizontal', variable=self.slider_col_max_var, + command=self.on_col_slider_changed) + self.scale_col_max.pack(fill='x', expand=True, padx=3, pady=(0, 1)) + self.label_col_max = ttk.Label(scrollable_frame, text=str(timePoint - 1), font=('Arial', 8), foreground='blue') + self.label_col_max.pack(anchor='e', padx=3, pady=(0, 3)) + + # Разделитель + ttk.Separator(scrollable_frame, orient='horizontal').pack(fill='x', padx=3, pady=3) + + # Кнопка автопрокрутки + follow_check = tk.Checkbutton(scrollable_frame, text="📌 Автопрокрутка", + variable=self.auto_follow, font=('Arial', 8), + bg='white', activebackground='white', activeforeground='black', + command=self.on_auto_follow_changed) + follow_check.pack(anchor='w', padx=3, pady=2) + + # Кнопки + button_frame = ttk.Frame(scrollable_frame) + button_frame.pack(fill='x', padx=3, pady=2) + + reset_btn = ttk.Button(button_frame, text='🔄 Сброс', command=self.on_reset_clicked) + reset_btn.pack(fill='x', pady=1) + + export_btn = ttk.Button(button_frame, text='💾 Экспорт', command=self.on_export_clicked) + export_btn.pack(fill='x', pady=1) + + # Разделитель + ttk.Separator(scrollable_frame, orient='horizontal').pack(fill='x', padx=3, pady=3) + + # Статус + status_label = ttk.Label(scrollable_frame, text="Статус:", font=('Arial', 9, 'bold')) + status_label.pack(anchor='w', padx=3) + + self.status_text = ttk.Label(scrollable_frame, text='Ожидание...', + font=('Arial', 7, 'italic'), foreground='blue', + wraplength=160, justify='left') + self.status_text.pack(anchor='w', padx=3, pady=2) + + # Разделитель + ttk.Separator(scrollable_frame, orient='horizontal').pack(fill='x', padx=3, pady=3) + + # Статистика + stats_label = ttk.Label(scrollable_frame, text="Статистика:", font=('Arial', 9, 'bold')) + stats_label.pack(anchor='w', padx=3) + + self.skipped_text = ttk.Label(scrollable_frame, text='Пропущено: 0', + font=('Arial', 8), foreground='orange') + self.skipped_text.pack(anchor='w', padx=3, pady=1) + + self.processed_text = ttk.Label(scrollable_frame, text='Обработано: 0', + font=('Arial', 8), foreground='green') + self.processed_text.pack(anchor='w', padx=3, pady=1) + + # ✓ ИСПРАВЛЕНИЕ: Счётчик пропущенных кадров + self.gap_frames_text = ttk.Label(scrollable_frame, text='Пропущено кадров: 0', + font=('Arial', 8), foreground='red') + self.gap_frames_text.pack(anchor='w', padx=3, pady=1) + + self.bscan_text = ttk.Label(scrollable_frame, text='B-scan: 0 columns', + font=('Arial', 8), foreground='purple') + self.bscan_text.pack(anchor='w', padx=3, pady=1) + + self.time_text = ttk.Label(scrollable_frame, text='Время: --:--:--', + font=('Arial', 8), foreground='green') + self.time_text.pack(anchor='w', padx=3, pady=1) + + def draw_empty_plots(self): + """Рисует пустые графики с подписями.""" + self.ax_raw.cla() + self.ax_raw.plot([], []) + self.ax_raw.set_xlabel('Такты', fontsize=9) + self.ax_raw.set_ylabel('Сигнал', fontsize=9) + self.ax_raw.set_title('Данные с АЦП', fontsize=10) + self.ax_raw.grid(True, alpha=0.3) + self.ax_raw.tick_params(labelsize=8) + + self.ax_processed.cla() + self.ax_processed.plot([], []) + self.ax_processed.set_xlabel('Частота', fontsize=9) + self.ax_processed.set_ylabel('Сигнал', fontsize=9) + self.ax_processed.set_title('Обработанные данные', fontsize=10) + self.ax_processed.grid(True, alpha=0.3) + self.ax_processed.tick_params(labelsize=8) + + self.ax_fourier.cla() + self.ax_fourier.plot([], []) + self.ax_fourier.set_title('Фурье образ', fontsize=10) + self.ax_fourier.grid(True, alpha=0.3) + self.ax_fourier.tick_params(labelsize=8) + + self.ax_bscan.cla() + self.ax_bscan.set_title('B-Scan (динамический)', fontsize=10) + self.ax_bscan.set_xlabel('Время →', fontsize=9) + self.ax_bscan.set_ylabel('Частота/Дистанция', fontsize=9) + self.ax_bscan.tick_params(labelsize=8) + + self.canvas.draw() + + # ============================================================================ + # CALLBACKS ИНТЕРФЕЙСА + # ============================================================================ + + def on_file_interval_changed(self): + """Callback при изменении интервала между файлами через Spinbox.""" + try: + new_interval = self.interval_var.get() + if 50 <= new_interval <= 5000: + self.file_interval_ms = new_interval + self.time_gap_threshold_ms = new_interval * GAP_THRESHOLD_MULTIPLIER + self.interval_label.config(text=f"{new_interval}ms") + self.interval_scale.set(new_interval) + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print( + f"[{timestamp}] 📊 File interval changed to {new_interval}ms (gap threshold: {self.time_gap_threshold_ms:.0f}ms)") + else: + self.interval_var.set(self.file_interval_ms) + except: + self.interval_var.set(self.file_interval_ms) + + def on_interval_scale_changed(self, val): + """Callback при изменении интервала между файлами через Scale.""" + try: + new_interval = int(float(val)) + if 50 <= new_interval <= 5000: + self.file_interval_ms = new_interval + self.time_gap_threshold_ms = new_interval * GAP_THRESHOLD_MULTIPLIER + self.interval_var.set(new_interval) + self.interval_label.config(text=f"{new_interval}ms") + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print( + f"[{timestamp}] 📊 File interval changed to {new_interval}ms (via slider, gap threshold: {self.time_gap_threshold_ms:.0f}ms)") + else: + self.interval_scale.set(self.file_interval_ms) + except: + self.interval_scale.set(self.file_interval_ms) + + def on_poll_interval_changed(self): + """Callback при изменении времени опроса файлов через Spinbox.""" + try: + new_interval = self.poll_interval_var.get() + if 10 <= new_interval <= 5000: + self.file_poll_interval_ms = new_interval + self.poll_label.config(text=f"{new_interval}ms") + self.poll_scale.set(new_interval) + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print(f"[{timestamp}] ⏱️ Poll interval changed to {new_interval}ms") + else: + self.poll_interval_var.set(self.file_poll_interval_ms) + except: + self.poll_interval_var.set(self.file_poll_interval_ms) + + def on_poll_scale_changed(self, val): + """Callback при изменении времени опроса файлов через Scale.""" + try: + new_interval = int(float(val)) + if 10 <= new_interval <= 5000: + self.file_poll_interval_ms = new_interval + self.poll_interval_var.set(new_interval) + self.poll_label.config(text=f"{new_interval}ms") + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print(f"[{timestamp}] ⏱️ Poll interval changed to {new_interval}ms (via slider)") + else: + self.poll_scale.set(self.file_poll_interval_ms) + except: + self.poll_scale.set(self.file_poll_interval_ms) + + def on_slider_changed(self, val=None): + """Callback при изменении слайдеров Row Min/Max.""" + self.bscan_row_min = self.slider_row_min_var.get() + self.bscan_row_max = self.slider_row_max_var.get() + + self.label_row_min.config(text=str(self.bscan_row_min)) + self.label_row_max.config(text=str(self.bscan_row_max)) + + self.redraw_bscan_immediate() + + def on_col_slider_changed(self, val=None): + """Callback для Col Min/Max - обновляет B_scan и перерисовывает.""" + self.bscan_col_min = self.slider_col_min_var.get() + self.bscan_col_max = self.slider_col_max_var.get() + + self.label_col_min.config(text=str(self.bscan_col_min)) + self.label_col_max.config(text=str(self.bscan_col_max)) + + # Отключаем автопрокрутку при ручном выборе + self.manual_range_selection = True + self.auto_follow.set(False) + + # СРАЗУ перерисовываем B_scan + self.redraw_bscan_immediate() + + def on_auto_follow_changed(self): + """Callback при изменении автопрокрутки.""" + if self.auto_follow.get(): + self.manual_range_selection = False + + def on_reset_clicked(self): + """Callback при нажатии кнопки Reset.""" + self.slider_row_min_var.set(0) + self.slider_row_max_var.set(STANDARD_FOURIER_SIZE - 1) + self.slider_col_min_var.set(0) + self.slider_col_max_var.set(min(timePoint - 1, max(0, len(self.B_scan_data) - 1))) + self.manual_range_selection = False + self.auto_follow.set(True) + + def on_export_clicked(self): + """Callback для экспорта графиков.""" + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")[:-3] + filename = f"export_{timestamp}.png" + self.fig.savefig(filename, dpi=150, bbox_inches='tight') + print(f"✓ Экспортировано: {filename}") + + def redraw_bscan_immediate(self): + """Немедленная перерисовка B_scan при изменении ползунков.""" + if len(self.B_scan_data) == 0: + return + + total_cols = len(self.B_scan_data) + + col_min = min(self.bscan_col_min, total_cols - 1) + col_max = min(self.bscan_col_max + 1, total_cols) + + # Проверяем корректность диапазона + if col_min >= col_max: + return + + row_min = self.bscan_row_min + row_max = self.bscan_row_max + + # Собираем данные для отображения + display_cols = [] + display_types = [] # Тип каждого столбца + # Предрасчёт фонового усреднения по строкам текущего окна + bg_vec = None + if self.bg_subtract_enabled and self.bscan_buffer: + desired_row_start = row_min + desired_row_end = row_max + 1 + bg_vec = self.compute_background_slice(desired_row_start, desired_row_end) + alpha = self.bg_subtract_alpha if self.bg_subtract_enabled else 0.0 + for i in range(col_min, col_max): + col_data = self.B_scan_data[i] + col_type = self.B_scan_types[i] + # Берём диапазон по высоте + if len(col_data) > row_min: + row_end = min(row_max + 1, len(col_data)) + row_start = min(row_min, len(col_data) - 1) + if row_end > row_start: + seg = col_data[row_start:row_end] + if bg_vec is not None and col_type != "GAP": + take = min(len(seg), len(bg_vec)) + if take > 0 and alpha != 0.0: + seg = seg.copy() + seg[:take] = seg[:take] - alpha * bg_vec[:take] + display_cols.append(seg) + display_types.append(col_type) + + display_times = self.B_scan_times[col_min:col_max] + + # Перерисовываем B_scan + self.ax_bscan.cla() + if display_cols: + # Находим максимальную длину для создания матрицы + max_len = max(len(col) for col in display_cols) + + # Создаём матрицу, заполняя нулями если необходимо + display_data = np.zeros((max_len, len(display_cols))) + for i, col in enumerate(display_cols): + display_data[:len(col), i] = col + + # Проверяем столбцы на GAP + gap_mask = np.array([col_type == "GAP" for col_type in display_types]) + + # Применяем маску: GAP столбцы становятся белыми (все = max_val * 1.5) + max_val = display_data.max() + if max_val > 0: + for i, is_gap in enumerate(gap_mask): + if is_gap: + display_data[:, i] = max_val * 1.5 # Белый (выше максимума) + + im = self.ax_bscan.imshow(display_data, origin='lower', aspect='auto', cmap='viridis') + + time_labels = [t.strftime("%H:%M:%S.%f")[:-3] if t else "?" for t in display_times] + + if len(time_labels) > 1: + step = max(1, len(time_labels) // 10) + tick_positions = np.arange(0, len(time_labels), step) + tick_labels_subset = [time_labels[i] if i < len(time_labels) else "" for i in tick_positions] + self.ax_bscan.set_xticks(tick_positions) + self.ax_bscan.set_xticklabels(tick_labels_subset, rotation=45, ha='right', fontsize=7) + else: + self.ax_bscan.set_xticks([]) + + self.ax_bscan.set_title(f'B-Scan (Cols {col_min}-{col_max - 1})', fontsize=10) + self.ax_bscan.set_xlabel('Время →', fontsize=9) + self.ax_bscan.set_ylabel(f'Диапазон [{row_min}-{row_max}]', fontsize=9) + self.ax_bscan.tick_params(labelsize=8) + + # Сразу рисуем + self.canvas.draw() + + # ============================================================================ + # ОБРАБОТКА ДАННЫХ + # ============================================================================ + + def process_raw_data(self, A, original_size): + """Обработка сырых данных.""" + A_1d = A[:, 0] if A.ndim > 1 else A + A_resized = resize_1d_interpolate(A_1d, STANDARD_RAW_SIZE) + + if self.SizeFirst != STANDARD_RAW_SIZE: + self.SizeFirst = STANDARD_RAW_SIZE + self.time_idx = np.arange(1, self.SizeFirst + 1) + self.B = None + self.meandr = None + self.k = 1 + self.SUM = 0.0 + + if self.k < PeriodIntegrate: + if isinstance(self.SUM, float) and self.SUM == 0.0: + self.SUM = A_resized.astype(float) + else: + self.SUM = self.SUM + A_resized + + if self.k == 1: + self.B = np.zeros((self.SizeFirst, PeriodIntegrate + 2)) + self.B[:, 0] = A_resized + self.meandr = square(self.time_idx * np.pi) + self.timestart2 = time.perf_counter() + else: + self.B[:, self.k] = A_resized + + self.k += 1 + return False, None + + else: + last_col = PeriodIntegrate + 1 + self.B[:, last_col] = A_resized + self.B = np.roll(self.B, 1, axis=1) + + if PeriodIntegrate > 1: + meanB = np.sum(self.B[:, 1:PeriodIntegrate], axis=1) / PeriodIntegrate + else: + meanB = self.B[:, 0] + + mwanBmeandr = meanB * self.meandr + + signal_list = [] + signalView_list = [] + timeSignal_list = [] + + start = 0 + numberOfFreqChangeStart = 0 + for numberOfFreqChange in range(self.SizeFirst): + if (numberOfFreqChange - start) > pontInOneFqChange: + segment = mwanBmeandr[numberOfFreqChangeStart:numberOfFreqChange] + if segment.size > 0: + signal_list.append(np.sum(segment)) + signalView_list.append(np.mean(segment)) + timeSignal_list.append(numberOfFreqChange) + start = numberOfFreqChange + numberOfFreqChangeStart = numberOfFreqChange + + self.signal = np.array(signal_list, dtype=float) + self.signalView = np.array(signalView_list, dtype=float) + self.timeSignal = np.array(timeSignal_list, dtype=int) + + self.compute_fft() + + if self.FshiftS is not None: + center = len(self.FshiftS) // 2 + src_start = center + FFT0_delta + src_end = src_start + height + if src_end <= len(self.FshiftS): + bscan_col = self.FshiftS[src_start:src_end].copy() + return True, bscan_col + + return True, None + + def process_sync_det_data(self, A, original_size): + """Обработка данных синхронного детектирования.""" + A_1d = A[:, 0] if A.ndim > 1 else A + A_resized = resize_1d_interpolate(A_1d, STANDARD_SYNC_SIZE) + + self.signal = A_resized + self.signalView = self.signal * 0.1 + self.timeSignal = np.arange(len(self.signal)) + + self.compute_fft() + + if self.FshiftS is not None: + center = len(self.FshiftS) // 2 + src_start = center + FFT0_delta + src_end = src_start + height + if src_end <= len(self.FshiftS): + bscan_col = self.FshiftS[src_start:src_end].copy() + return True, bscan_col + + return True, None + + def process_fourier_data(self, A, original_size): + """Обработка FOURIER без интерполяции. Поддерживает несколько сегментов.""" + columns_to_add = [] + + # A может быть: list[np.ndarray] (из HEX) или numpy.ndarray + if isinstance(A, list): + for seg in A: + # Если сегмент — кортеж (fourier_col, f0), отобразим F0 в временной области, + # но B-scan пополняем только спектром (fourier_col) + if isinstance(seg, tuple) and len(seg) == 2: + col = np.asarray(seg[0], dtype=float) + f0 = np.asarray(seg[1], dtype=float) + self.signal = f0 + self.signalView = f0 * 0.1 + self.timeSignal = np.arange(len(f0)) + columns_to_add.append(col) + else: + col = np.asarray(seg, dtype=float) + columns_to_add.append(col) + return True, columns_to_add + + if A.ndim == 1: + columns_to_add.append(A.astype(float)) + return True, columns_to_add + + # Если A двумерный: считаем колонками столбцы или строки — выбираем более длинное измерение как длину спектра + if A.ndim == 2: + rows, cols = A.shape + if rows >= cols: + for i in range(cols): + columns_to_add.append(A[:, i].astype(float)) + else: + for i in range(rows): + columns_to_add.append(A[i, :].astype(float)) + return True, columns_to_add + + return True, columns_to_add + + def add_bscan_column(self, data_col, current_time, data_type): + """Добавить колонку в B-скан (может быть разного размера).""" + + if self.last_file_time is None: + self.B_scan_data.append(data_col) + self.B_scan_times.append(current_time) + self.B_scan_types.append(data_type) + self.last_file_time = current_time + # Сохраняем в буфер для фонового усреднения (не добавляем GAP) + if data_type != "GAP": + self.bscan_buffer.append((np.asarray(data_col, dtype=float), current_time, data_type)) + self.prune_bscan_buffer(current_time) + return + + # Используем переменный интервал между файлами + time_diff_ms = (current_time - self.last_file_time).total_seconds() * 1000 + + if time_diff_ms > self.time_gap_threshold_ms: + missing_count = int(round(time_diff_ms / self.file_interval_ms)) - 1 + + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print( + f"[{timestamp}] ⚠️ Gap detected: {time_diff_ms:.1f}ms (missing ~{missing_count} columns, threshold: {self.time_gap_threshold_ms:.0f}ms)" + + (" — ignored" if self.ignore_lost else "")) + + # ✓ ИСПРАВЛЕНИЕ: Увеличиваем счётчик пропущенных кадров + self.gap_frames_count += missing_count + + if not self.ignore_lost: + last_size = len(self.B_scan_data[-1]) if self.B_scan_data else height + for i in range(missing_count): + zero_col = np.zeros(last_size) + self.B_scan_data.append(zero_col) + gap_time = self.last_file_time + timedelta(milliseconds=self.file_interval_ms * (i + 1)) + self.B_scan_times.append(gap_time) + # Отмечаем как GAP (пропущенный кадр) + self.B_scan_types.append("GAP") + + self.B_scan_data.append(data_col) + self.B_scan_times.append(current_time) + self.B_scan_types.append(data_type) + self.last_file_time = current_time + # Сохраняем в буфер для фонового усреднения (не добавляем GAP) + if data_type != "GAP": + self.bscan_buffer.append((np.asarray(data_col, dtype=float), current_time, data_type)) + self.prune_bscan_buffer(current_time) + + def prune_bscan_buffer(self, now_time: datetime): + """Удаляет столбцы старше окна усреднения.""" + try: + threshold = now_time - timedelta(seconds=self.bg_avg_window_sec) + while self.bscan_buffer and self.bscan_buffer[0][1] < threshold: + self.bscan_buffer.popleft() + except Exception: + pass + + def compute_background_slice(self, row_start: int, row_end: int) -> np.ndarray: + """Пер-строчная статистика (median/mean) по окну последних колонок для строк [row_start:row_end).""" + n_rows = max(0, row_end - row_start) + if n_rows <= 0 or not self.bscan_buffer: + return np.zeros((0,), dtype=float) + + # Собираем сегменты для указанного диапазона строк + segments = [] + for col, t, tname in self.bscan_buffer: + if col is None: + continue + L = len(col) + if L <= row_start: + continue + take = min(n_rows, L - row_start) + if take <= 0: + continue + seg = np.asarray(col[row_start:row_start + take], dtype=float) + segments.append(seg) + + if not segments: + return np.zeros((n_rows,), dtype=float) + + # Формируем матрицу (num_cols x n_rows) с NaN, заполняя доступные значения + num_cols = len(segments) + M = np.full((num_cols, n_rows), np.nan, dtype=float) + for i, seg in enumerate(segments): + take = min(n_rows, seg.shape[0]) + if take > 0: + M[i, :take] = seg[:take] + + # Агрегирование по времени (ось 0), игнорируя NaN + with np.errstate(all='ignore'): + if getattr(self, 'bg_method', 'median') == 'mean': + bg = np.nanmean(M, axis=0) + else: + bg = np.nanmedian(M, axis=0) + + # Заменим NaN на 0 (строки без данных в окне) + bg = np.nan_to_num(bg, nan=0.0) + return bg + + def compute_fft(self): + """Вычисляем FFT спектр.""" + if self.signal.size > 0: + sig_cut = self.signal[:FQend] if len(self.signal) >= FQend else self.signal + sig_cut = np.sqrt(np.abs(sig_cut)) + F = np.fft.fft(sig_cut) + Fshift = np.abs(np.fft.fftshift(F)) + center = len(sig_cut) // 2 + if center < len(Fshift): + Fshift[max(center - 0, 0):min(center + 1, len(Fshift))] = 0 + self.FshiftS = Fshift + #self.FshiftS = gaussian_filter1d(Fshift, 5) + + # ============================================================================ + # ОБНОВЛЕНИЕ GUI + # ============================================================================ + + def schedule_update(self, original_size, data_type): + """Отложить обновление GUI.""" + with self.update_lock: + self.pending_original_size = original_size + self.pending_data_type = data_type + self.pending_update = True + + def do_pending_update(self): + """Обновляем GUI.""" + with self.update_lock: + if not self.pending_update: + return + + original_size = self.pending_original_size + data_type = self.pending_data_type + self.pending_update = False + + # Извлекаем все элементы из очереди + while not self.bscan_queue.empty(): + try: + bscan_col, file_time, data_type_col = self.bscan_queue.get_nowait() + self.add_bscan_column(bscan_col, file_time, data_type_col) + except queue.Empty: + break + + # Обновление графиков + self.ax_raw.cla() + if self.B is not None and self.meandr is not None: + self.ax_raw.plot(self.time_idx, np.abs(self.B[:, 0] if self.B.ndim > 1 else self.B), + label='B', linewidth=0.5, alpha=0.7) + if self.timeSignal.size > 0: + self.ax_raw.plot(self.timeSignal, np.abs(self.signalView), linewidth=0.5) + self.ax_raw.set_xlabel('Такты', fontsize=9) + self.ax_raw.set_ylabel('Сигнал', fontsize=9) + self.ax_raw.set_title('Данные с АЦП', fontsize=10) + self.ax_raw.grid(True, alpha=0.3) + self.ax_raw.tick_params(labelsize=8) + + self.ax_processed.cla() + if self.signal.size > 0: + ssS = self.signal.size + perpointFq = 10.67 / ssS + XSignal = 3 + (np.arange(1, ssS + 1) * perpointFq) + self.ax_processed.plot(XSignal, np.abs(self.signal), linewidth=1) + self.ax_processed.set_xlabel('Частота', fontsize=9) + self.ax_processed.set_ylabel('Сигнал', fontsize=9) + self.ax_processed.set_title('Обработанные данные', fontsize=10) + self.ax_processed.grid(True, alpha=0.3) + self.ax_processed.tick_params(labelsize=8) + + self.ax_fourier.cla() + if self.FshiftS is not None: + self.ax_fourier.plot(self.FshiftS, linewidth=1) + self.ax_fourier.set_xlim([0, min(FQend, len(self.FshiftS))]) + self.ax_fourier.set_title('Фурье образ', fontsize=10) + self.ax_fourier.grid(True, alpha=0.3) + self.ax_fourier.tick_params(labelsize=8) + + # Отрисовываем B_scan с автопрокруткой + if len(self.B_scan_data) > 0: + total_cols = len(self.B_scan_data) + + if self.auto_follow.get() and not self.manual_range_selection: + self.bscan_col_min = max(0, total_cols - timePoint) + self.bscan_col_max = total_cols - 1 + self.slider_col_min_var.set(self.bscan_col_min) + self.slider_col_max_var.set(self.bscan_col_max) + + self.redraw_bscan_immediate() + + total_cols = len(self.B_scan_data) + self.bscan_text.config(text=f'B-scan: {total_cols} columns') + + # Обновляем статус + status_msg = f"Тип: {data_type}\nИсх: {original_size}" + self.status_text.config(text=status_msg) + + self.skipped_text.config(text=f'Пропущено: {self.skipped_count}') + self.processed_text.config(text=f'Обработано: {self.processed_count}') + + # ✓ ИСПРАВЛЕНИЕ: Обновляем счётчик пропущенных кадров + self.gap_frames_text.config(text=f'Пропущено кадров: {self.gap_frames_count}') + + if self.B_scan_times: + last_time = self.B_scan_times[-1].strftime("%H:%M:%S.%f")[:-3] + self.time_text.config(text=f'Время: {last_time}') + + # Обновляем диапазоны слайдеров + if len(self.B_scan_data) > 0: + self.scale_col_max.config(to=len(self.B_scan_data) - 1) + if not self.auto_follow.get(): + self.slider_col_max_var.set(min(self.slider_col_max_var.get(), len(self.B_scan_data) - 1)) + + # Принудительный draw() + self.canvas.draw() + + # ============================================================================ + # ОСНОВНОЙ ЦИКЛ ОБРАБОТКИ ФАЙЛОВ + # ============================================================================ + + def process_file_thread(self, fname, data_type, A, original_size): + """Обработка файла в отдельном потоке.""" + try: + # Если данные не были загружены в главном потоке (HEX отложен) — загрузим здесь + if A is None: + data_type, A = load_data_with_type(fname) + if isinstance(A, list): + original_size = len(A[0]) if len(A) > 0 else 0 + elif isinstance(A, np.ndarray): + original_size = A.shape[0] + else: + original_size = 0 + + file_time = get_file_time_with_milliseconds(fname) + #print("file:", fname) + #print("A:", A) + + bscan_col = None + add_to_bscan = False + + if data_type == DATA_TYPE_RAW: + # Может прийти список сегментов (HEX с FE) + if isinstance(A, list): + for i, seg in enumerate(A): + add_to_bscan, bscan_col = self.process_raw_data(np.asarray(seg), len(seg)) + if add_to_bscan and bscan_col is not None: + col_time = file_time + timedelta(milliseconds=i * 10) + self.bscan_queue.put((bscan_col, col_time, DATA_TYPE_RAW)) + add_to_bscan, bscan_col = False, None + else: + add_to_bscan, bscan_col = self.process_raw_data(A, original_size) + elif data_type == DATA_TYPE_SYNC_DET: + if isinstance(A, list): + for i, seg in enumerate(A): + add_to_bscan, bscan_col = self.process_sync_det_data(np.asarray(seg), len(seg)) + if add_to_bscan and bscan_col is not None: + col_time = file_time + timedelta(milliseconds=i * 10) + self.bscan_queue.put((bscan_col, col_time, DATA_TYPE_SYNC_DET)) + add_to_bscan, bscan_col = False, None + else: + add_to_bscan, bscan_col = self.process_sync_det_data(A, original_size) + elif data_type == DATA_TYPE_FOURIER: + add_to_bscan, columns = self.process_fourier_data(A, original_size) + if add_to_bscan and columns: + for i, col in enumerate(columns): + col_time = file_time + timedelta(milliseconds=i * 10) + self.bscan_queue.put((col, col_time, DATA_TYPE_FOURIER)) + # Обновляем график Фурье спектром из файла (берём последний столбец) + try: + self.FshiftS = np.asarray(columns[-1], dtype=float) + except Exception: + pass + bscan_col = None + + if add_to_bscan and bscan_col is not None and data_type != DATA_TYPE_FOURIER: + self.bscan_queue.put((bscan_col, file_time, data_type)) + + self.schedule_update(original_size, data_type) + self.processed_count += 1 + + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + file_time_str = file_time.strftime("%H:%M:%S.%f")[:-3] + print(f"[{timestamp}] ✓ {fname} ({data_type}) [FileTime: {file_time_str}]") + + except Exception as e: + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print(f"[{timestamp}] ✗ Error: {str(e)[:50]}") + + def run(self): + """Основной цикл.""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + print("=" * 80) + print(f" Radar Data Analyzer - Started: {timestamp}") + print(f" Waiting for .txt files in: {self.data_dir}") + print(f" RAW/SYNC_DET A-scan height: {height} pixels") + print(f" FOURIER A-scan size: {STANDARD_FOURIER_SIZE} pixels (N/2)") + print(f" B-scan: Time-synchronized (milliseconds), display range selectable") + print(f" File interval: {self.file_interval_ms}ms (user adjustable)") + print(f" Gap threshold: {self.time_gap_threshold_ms:.0f}ms ({GAP_THRESHOLD_MULTIPLIER}x interval)") + print(f" Poll interval: {self.file_poll_interval_ms}ms (user adjustable)") + print(f" FOURIER_MODE: {FOURIER_MODE}") + print(f" B-Scan display: GAP (lost frames) shown as WHITE") + print("=" * 80 + "\n") + + self.process_files() + + def process_files(self): + """Обработка файлов в цикле.""" + files = sorted([ + f for f in os.listdir() + if f.lower().endswith(('.txt', '.txt1', '.txt2', '.csv')) + ]) + + new_files = [f for f in files if f not in self.processed_files] + + for fname in new_files: + time_start = time.perf_counter() + + try: + # Быстро определим тип по первой строке (без полного чтения файла) + with open(fname, 'r') as f: + head = f.readline() + quick_type = detect_data_type(head) + + if quick_type == DATA_TYPE_HEX: + # Отложенный парсинг HEX в фоне, чтобы не блокировать UI и не превышать таймаут + thread = threading.Thread( + target=self.process_file_thread, + args=(fname, DATA_TYPE_HEX, None, 0), + daemon=True + ) + thread.start() + self.processed_files.add(fname) + continue + + # Для остальных типов загрузим сразу и применим лимит времени + data_type, A = load_data_with_type(fname) + if isinstance(A, list): + original_size = len(A[0]) if len(A) > 0 else 0 + elif isinstance(A, np.ndarray): + original_size = A.shape[0] + else: + original_size = 0 + + # Фильтрация неполных/нулевых данных + if data_type == DATA_TYPE_SYNC_DET: + # SYNC_DET соответствует F0; проверяем минимальную длину и ненулевость + if isinstance(A, np.ndarray): + arr = A.ravel() + if arr.size < MIN_F0_POINTS or is_all_zero(arr): + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + reason = f"len={arr.size} < {MIN_F0_POINTS}" if arr.size < MIN_F0_POINTS else "all zeros" + print(f"[{timestamp}] ⏭️ SKIP {fname} (SYNC_DET invalid: {reason})") + self.skipped_count += 1 + self.processed_files.add(fname) + continue + elif isinstance(A, list): + filtered = [] + for seg in A: + try: + arr = np.asarray(seg, dtype=float).ravel() + if arr.size >= MIN_F0_POINTS and (not is_all_zero(arr)): + filtered.append(arr) + except Exception: + continue + if not filtered: + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print(f"[{timestamp}] ⏭️ SKIP {fname} (SYNC_DET segments invalid)") + self.skipped_count += 1 + self.processed_files.add(fname) + continue + A = filtered + original_size = len(A[0]) if len(A) > 0 else 0 + + if data_type == DATA_TYPE_FOURIER: + # Для FOURIER: пропускаем полностью пустые/нулевые массивы + if isinstance(A, np.ndarray): + arr = A.ravel() + if arr.size == 0 or is_all_zero(arr): + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print(f"[{timestamp}] ⏭️ SKIP {fname} (FOURIER empty or all zeros)") + self.skipped_count += 1 + self.processed_files.add(fname) + continue + + # Если после парсинга данных нет — пропускаем файл + if (isinstance(A, list) and len(A) == 0) or (isinstance(A, np.ndarray) and A.size == 0): + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print(f"[{timestamp}] ⏭️ SKIP {fname} (no data parsed)") + self.skipped_count += 1 + self.processed_files.add(fname) + continue + + elapsed_time_ms = (time.perf_counter() - time_start) * 1000 + + if elapsed_time_ms > MAX_PROCESSING_TIME_MS: + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print(f"[{timestamp}] ⏭️ SKIP {fname} (load time: {elapsed_time_ms:.1f}ms)") + self.skipped_count += 1 + else: + thread = threading.Thread( + target=self.process_file_thread, + args=(fname, data_type, A, original_size), + daemon=True + ) + thread.start() + + self.processed_files.add(fname) + + except Exception as e: + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print(f"[{timestamp}] ✗ Load error: {str(e)[:50]}") + self.processed_files.add(fname) + + self.do_pending_update() + # Очистка старых файлов, чтобы хранить не более FILES_STORED_N_MAX + #self.cleanup_old_files() //stores only last FILES_STORED_N_MAX files + self.cleanup_by_size(max_bytes=500*1024*1024) #stores not more than 0.5 Gb of data + # Используем переменное время опроса + self.root.after(self.file_poll_interval_ms, self.process_files) + + def cleanup_old_files(self): + try: + files = [ + f for f in os.listdir() + if f.lower().endswith(('.txt', '.txt1', '.txt2', '.csv')) + ] + if len(files) <= FILES_STORED_N_MAX: + return + # Сортировка по времени изменения (старые сначала) + files_sorted = sorted(files, key=lambda fn: os.path.getmtime(fn)) + to_delete = files_sorted[:len(files) - FILES_STORED_N_MAX] + deleted = 0 + for fn in to_delete: + try: + os.remove(fn) + deleted += 1 + # Поддерживаем список обработанных + if fn in self.processed_files: + self.processed_files.discard(fn) + except Exception: + pass + if deleted: + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + print(f"[{timestamp}] 🧹 Cleanup: removed {deleted} old file(s)") + except Exception: + pass + + def cleanup_by_size(self, max_bytes=500 * 1024 * 1024): + """Альтернативный сборщик: удаляет самые старые .csv/.txt, + если суммарный объём в папке превышает max_bytes (по умолчанию ~500 МБ).""" + try: + # Берём только обычные файлы текущей директории данных + files = [ + f for f in os.listdir() + if os.path.isfile(f) and f.lower().endswith(('.csv', '.txt')) + ] + # Подсчёт общего объёма + meta = [] # (name, size, mtime) + total_size = 0 + for fn in files: + try: + sz = os.path.getsize(fn) + mt = os.path.getmtime(fn) + meta.append((fn, sz, mt)) + total_size += sz + except Exception: + continue + + if total_size <= max_bytes: + return + + # Сортируем по времени изменения (старые сначала) и удаляем, пока не уложимся + meta.sort(key=lambda t: t[2]) + deleted = 0 + for fn, sz, _ in meta: + if total_size <= max_bytes: + break + try: + os.remove(fn) + total_size -= sz + deleted += 1 + if fn in self.processed_files: + self.processed_files.discard(fn) + except Exception: + continue + + if deleted: + timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] + mb = max_bytes / (1024 * 1024) + print(f"[{timestamp}] 🧹 Collector: removed {deleted} file(s) to keep <= {mb:.0f} MB") + except Exception: + pass + + +# ================================================================================ +# ТОЧКА ВХОДА +# ================================================================================ + +if __name__ == "__main__": + if len(argv) == 2: + data_dir = argv[1] + print("data dir:", data_dir) + root = tk.Tk() + app = DataAnalyzerApp(root) + app.run() + + try: + root.mainloop() + except KeyboardInterrupt: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + print("\n" + "=" * 80) + print(f" Program stopped by user: {timestamp}") + print("=" * 80)