This commit is contained in:
awe
2025-12-02 16:46:18 +03:00
3 changed files with 1953 additions and 1533 deletions

35
AGENTS.md Normal file
View File

@ -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 Bscan/Fourier views.
- `datagen.py`: Test data generator for RAW, SYNC_DET, and FOURIER; produces timestamped 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 4space 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 filebased tests; dont write to system paths.
- Aim for ≥70% coverage on new modules. Add smoke tests for file parsing and queue/processing logic; use a noninteractive Matplotlib backend for tests.
## Commit & Pull Request Guidelines
- Commits: imperative mood with scope, e.g., `gui: improve Bscan 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 nonWindows 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.

53
datagen.py Normal file → Executable file
View File

@ -1,13 +1,16 @@
#!/usr/bin/python3
import os import os
import time import time
import numpy as np import numpy as np
from datetime import datetime, timedelta from datetime import datetime, timedelta
#from builtins import True
# ================================================================================ # ================================================================================
# ПАРАМЕТРЫ ЭМУЛЯЦИИ # ПАРАМЕТРЫ ЭМУЛЯЦИИ
# ================================================================================ # ================================================================================
DATA_DIR = r"D:\data" #DATA_DIR = r"D:\data"
DATA_DIR = './data'
# ✓ ИСПРАВЛЕНИЕ: Выбор типа данных и количество # ✓ ИСПРАВЛЕНИЕ: Выбор типа данных и количество
NUM_FILES = 1000 # Количесйтво файлов для генерации NUM_FILES = 1000 # Количесйтво файлов для генерации
@ -89,7 +92,7 @@ def create_fourier_data(sync_det_data=None, fft_size=SYNC_DET_SIZE, output_size=
# ГЕНЕРАЦИЯ ФАЙЛОВ # ГЕНЕРАЦИЯ ФАЙЛОВ
# ================================================================================ # ================================================================================
def emit_raw_files(count=NUM_FILES, start_time=None): def emit_raw_files(count=NUM_FILES, start_time=None, hex_mode=True):
"""Генерирует RAW файлы количеством count.""" """Генерирует RAW файлы количеством count."""
if start_time is None: if start_time is None:
start_time = datetime.now() start_time = datetime.now()
@ -115,9 +118,18 @@ def emit_raw_files(count=NUM_FILES, start_time=None):
# Добавляем заголовок # Добавляем заголовок
filepath = os.path.join(DATA_DIR, filename) filepath = os.path.join(DATA_DIR, filename)
with open(filepath, 'w') as f: 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") f.write("RAW\n")
np.savetxt(f, data, fmt='%.6f') np.savetxt(f, data, fmt='%.6f')
# Устанавливаем время модификации # Устанавливаем время модификации
timestamp = file_time.timestamp() timestamp = file_time.timestamp()
os.utime(filepath, (timestamp, timestamp)) os.utime(filepath, (timestamp, timestamp))
@ -130,7 +142,7 @@ def emit_raw_files(count=NUM_FILES, start_time=None):
return start_time + timedelta(milliseconds=count * FILE_INTERVAL_MS) return start_time + timedelta(milliseconds=count * FILE_INTERVAL_MS)
def emit_sync_det_files(count=NUM_FILES, start_time=None): def emit_sync_det_files(count=NUM_FILES, start_time=None, hex_mode=True):
"""Генерирует SYNC_DET файлы количеством count.""" """Генерирует SYNC_DET файлы количеством count."""
if start_time is None: if start_time is None:
start_time = datetime.now() start_time = datetime.now()
@ -152,10 +164,17 @@ def emit_sync_det_files(count=NUM_FILES, start_time=None):
# Генерируем данные # Генерируем данные
data = create_sync_det_data(index=i) data = create_sync_det_data(index=i)
#print("data:", data)
# Добавляем заголовок # Добавляем заголовок
filepath = os.path.join(DATA_DIR, filename) filepath = os.path.join(DATA_DIR, filename)
with open(filepath, 'w') as f: 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") f.write("SYNC_DET\n")
np.savetxt(f, data, fmt='%.6f') np.savetxt(f, data, fmt='%.6f')
@ -171,7 +190,7 @@ def emit_sync_det_files(count=NUM_FILES, start_time=None):
return start_time + timedelta(milliseconds=count * FILE_INTERVAL_MS) return start_time + timedelta(milliseconds=count * FILE_INTERVAL_MS)
def emit_fourier_files(count=NUM_FILES, start_time=None): def emit_fourier_files(count=NUM_FILES, start_time=None, hex_mode=True):
"""✓ Генерирует FOURIER файлы количеством count. """✓ Генерирует FOURIER файлы количеством count.
Каждый файл содержит амплитудный спектр размером 500. Каждый файл содержит амплитудный спектр размером 500.
@ -211,9 +230,16 @@ def emit_fourier_files(count=NUM_FILES, start_time=None):
# Добавляем заголовок # Добавляем заголовок
filepath = os.path.join(DATA_DIR, filename) filepath = os.path.join(DATA_DIR, filename)
with open(filepath, 'w') as f: 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") f.write("FOURIER\n")
np.savetxt(f, data, fmt='%.6f') np.savetxt(f, data, fmt='%.6f')
# Устанавливаем время модификации # Устанавливаем время модификации
timestamp = file_time.timestamp() timestamp = file_time.timestamp()
os.utime(filepath, (timestamp, timestamp)) os.utime(filepath, (timestamp, timestamp))
@ -261,8 +287,19 @@ if __name__ == "__main__":
show_menu() show_menu()
while True: while True:
try: 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() choice = input(" Введите номер (1/2/3) или 'q' для выхода: ").strip().lower()
if choice == 'q': if choice == 'q':
@ -270,18 +307,20 @@ if __name__ == "__main__":
break break
elif choice == '1': elif choice == '1':
start_time = datetime.now().replace(microsecond=0) start_time = datetime.now().replace(microsecond=0)
emit_raw_files(NUM_FILES, start_time) emit_raw_files(NUM_FILES, start_time, hex_mode=hex_mode)
break break
elif choice == '2': elif choice == '2':
start_time = datetime.now().replace(microsecond=0) start_time = datetime.now().replace(microsecond=0)
emit_sync_det_files(NUM_FILES, start_time) emit_sync_det_files(NUM_FILES, start_time, hex_mode=hex_mode)
break break
elif choice == '3': elif choice == '3':
start_time = datetime.now().replace(microsecond=0) start_time = datetime.now().replace(microsecond=0)
emit_fourier_files(NUM_FILES, start_time) emit_fourier_files(NUM_FILES, start_time, hex_mode=hex_mode)
break break
else: else:
print(" ❌ Неверный выбор. Введите 1, 2, 3 или q") print(" ❌ Неверный выбор. Введите 1, 2, 3 или q")
except KeyboardInterrupt: except KeyboardInterrupt:
print("\n\n Прервано пользователем.") print("\n\n Прервано пользователем.")
break break

378
main.py
View File

@ -1,4 +1,5 @@
#!/usr/bin/python3 #!/usr/bin/python3
import os import os
import time import time
import numpy as np import numpy as np
@ -12,6 +13,8 @@ from scipy.interpolate import interp1d
from datetime import datetime, timedelta from datetime import datetime, timedelta
import threading import threading
import queue import queue
from sys import argv
from collections import deque
# ================================================================================ # ================================================================================
# ПАРАМЕТРЫ И КОНСТАНТЫ # ПАРАМЕТРЫ И КОНСТАНТЫ
@ -38,6 +41,17 @@ STANDARD_FOURIER_ROWS = 60
STANDARD_FOURIER_COLS = 100 STANDARD_FOURIER_COLS = 100
MAX_PROCESSING_TIME_MS = 250 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_RAW = "RAW"
@ -56,7 +70,13 @@ DEFAULT_FILE_INTERVAL_MS = 300 # 300 мс
GAP_THRESHOLD_MULTIPLIER = 1.5 GAP_THRESHOLD_MULTIPLIER = 1.5
# Начальное время опроса файлов (в миллисекундах) # Начальное время опроса файлов (в миллисекундах)
DEFAULT_FILE_POLL_INTERVAL_MS = 100 # 100 мс DEFAULT_FILE_POLL_INTERVAL_MS = 40 # 100 мс
# Игнорировать пропущенные данные (не добавлять GAP-колонки)
IGNORE_LOST = True
# Игнорировать спектры из F4 и рассчитывать FFT из временных данных
IGNORE_F4_FFT_DATA = True
# ================================================================================ # ================================================================================
@ -114,6 +134,53 @@ def resize_2d_interpolate(data, target_rows, target_cols):
return data_resampled 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): def load_data_with_type(filename):
"""Загружает данные и определяет их тип по первой строке.""" """Загружает данные и определяет их тип по первой строке."""
with open(filename, 'r') as f: with open(filename, 'r') as f:
@ -153,19 +220,47 @@ def parse_hex_file(filename):
seg_sync = [] seg_sync = []
seg_fourier = [] 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(): def finalize_segment():
nonlocal cur nonlocal cur
# Приоритет выбора, что считать сегментом # Приоритет выбора сегмента:
if cur["F4"]: # 1) Если есть F4 — используем как FOURIER; F0 (если есть) передаём для отображения без расчёта FFT
seg_fourier.append(np.asarray(cur["F4"], dtype=float)) # 2) Иначе F1+F2 → амплитуда
elif cur["F3"]: # 3) Иначе F3 (sqrt)
arr = np.asarray(cur["F3"], dtype=float) # 4) Иначе F0 как SYNC_DET
seg_fourier.append(np.sqrt(np.maximum(0.0, arr))) # 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"]): elif cur["F1"] and cur["F2"] and len(cur["F1"]) == len(cur["F2"]):
re = np.asarray(cur["F1"], dtype=float) re = np.asarray(cur["F1"], dtype=float)
im = np.asarray(cur["F2"], dtype=float) im = np.asarray(cur["F2"], dtype=float)
seg_fourier.append(np.sqrt(re * re + im * im)) 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"]: elif cur["F0"]:
if _f0_is_valid_local(cur["F0"]):
seg_sync.append(np.asarray(cur["F0"], dtype=float)) seg_sync.append(np.asarray(cur["F0"], dtype=float))
elif cur["D0"]: elif cur["D0"]:
seg_raw.append(np.asarray(cur["D0"], dtype=float)) seg_raw.append(np.asarray(cur["D0"], dtype=float))
@ -272,12 +367,16 @@ class DataAnalyzerApp:
def __init__(self, root): def __init__(self, root):
self.root = root self.root = root
self.root.title("Radar Data Analyzer (Time Synchronized - Queue Based)") self.root.title("Radar Data Analyzer (Time Synchronized - Queue Based)")
self.root.geometry("1500x850") # Уменьшено, чтобы помещалось на экране 1024x768
self.root.geometry("1000x720")
self.data_dir = data_dir self.data_dir = data_dir
os.makedirs(self.data_dir, exist_ok=True) os.makedirs(self.data_dir, exist_ok=True)
os.chdir(self.data_dir) os.chdir(self.data_dir)
# Настройка: игнорировать пропуски кадров
self.ignore_lost = IGNORE_LOST
# Инициализируем с существующими файлами # Инициализируем с существующими файлами
existing_files = sorted([ existing_files = sorted([
f for f in os.listdir() f for f in os.listdir()
@ -362,6 +461,13 @@ class DataAnalyzerApp:
self.processed_count = 0 self.processed_count = 0
self.skipped_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
# ============================================================================ # ============================================================================
# ИНИЦИАЛИЗАЦИЯ ИНТЕРФЕЙСА # ИНИЦИАЛИЗАЦИЯ ИНТЕРФЕЙСА
# ============================================================================ # ============================================================================
@ -374,7 +480,8 @@ class DataAnalyzerApp:
fig_frame = ttk.Frame(self.left_frame) fig_frame = ttk.Frame(self.left_frame)
fig_frame.pack(fill='both', expand=True, padx=3, pady=3) fig_frame.pack(fill='both', expand=True, padx=3, pady=3)
self.fig = plt.Figure(figsize=(9, 6.5), dpi=100) # Уменьшен стартовый размер фигуры, чтобы влезать в 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) 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_raw = self.fig.add_subplot(gs[0, 0])
@ -762,6 +869,13 @@ class DataAnalyzerApp:
# Собираем данные для отображения # Собираем данные для отображения
display_cols = [] display_cols = []
display_types = [] # Тип каждого столбца 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): for i in range(col_min, col_max):
col_data = self.B_scan_data[i] col_data = self.B_scan_data[i]
col_type = self.B_scan_types[i] col_type = self.B_scan_types[i]
@ -770,7 +884,13 @@ class DataAnalyzerApp:
row_end = min(row_max + 1, len(col_data)) row_end = min(row_max + 1, len(col_data))
row_start = min(row_min, len(col_data) - 1) row_start = min(row_min, len(col_data) - 1)
if row_end > row_start: if row_end > row_start:
display_cols.append(col_data[row_start:row_end]) 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_types.append(col_type)
display_times = self.B_scan_times[col_min:col_max] display_times = self.B_scan_times[col_min:col_max]
@ -923,6 +1043,16 @@ class DataAnalyzerApp:
# A может быть: list[np.ndarray] (из HEX) или numpy.ndarray # A может быть: list[np.ndarray] (из HEX) или numpy.ndarray
if isinstance(A, list): if isinstance(A, list):
for seg in A: 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) col = np.asarray(seg, dtype=float)
columns_to_add.append(col) columns_to_add.append(col)
return True, columns_to_add return True, columns_to_add
@ -952,6 +1082,10 @@ class DataAnalyzerApp:
self.B_scan_times.append(current_time) self.B_scan_times.append(current_time)
self.B_scan_types.append(data_type) self.B_scan_types.append(data_type)
self.last_file_time = current_time 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 return
# Используем переменный интервал между файлами # Используем переменный интервал между файлами
@ -962,11 +1096,13 @@ class DataAnalyzerApp:
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
print( print(
f"[{timestamp}] ⚠️ Gap detected: {time_diff_ms:.1f}ms (missing ~{missing_count} columns, threshold: {self.time_gap_threshold_ms:.0f}ms)") 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 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 last_size = len(self.B_scan_data[-1]) if self.B_scan_data else height
for i in range(missing_count): for i in range(missing_count):
zero_col = np.zeros(last_size) zero_col = np.zeros(last_size)
@ -980,6 +1116,61 @@ class DataAnalyzerApp:
self.B_scan_times.append(current_time) self.B_scan_times.append(current_time)
self.B_scan_types.append(data_type) self.B_scan_types.append(data_type)
self.last_file_time = current_time 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): def compute_fft(self):
"""Вычисляем FFT спектр.""" """Вычисляем FFT спектр."""
@ -991,7 +1182,8 @@ class DataAnalyzerApp:
center = len(sig_cut) // 2 center = len(sig_cut) // 2
if center < len(Fshift): if center < len(Fshift):
Fshift[max(center - 0, 0):min(center + 1, len(Fshift))] = 0 Fshift[max(center - 0, 0):min(center + 1, len(Fshift))] = 0
self.FshiftS = gaussian_filter1d(Fshift, 5) self.FshiftS = Fshift
#self.FshiftS = gaussian_filter1d(Fshift, 5)
# ============================================================================ # ============================================================================
# ОБНОВЛЕНИЕ GUI # ОБНОВЛЕНИЕ GUI
@ -1100,7 +1292,19 @@ class DataAnalyzerApp:
def process_file_thread(self, fname, data_type, A, original_size): def process_file_thread(self, fname, data_type, A, original_size):
"""Обработка файла в отдельном потоке.""" """Обработка файла в отдельном потоке."""
try: 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) file_time = get_file_time_with_milliseconds(fname)
#print("file:", fname)
#print("A:", A)
bscan_col = None bscan_col = None
add_to_bscan = False add_to_bscan = False
@ -1132,6 +1336,11 @@ class DataAnalyzerApp:
for i, col in enumerate(columns): for i, col in enumerate(columns):
col_time = file_time + timedelta(milliseconds=i * 10) col_time = file_time + timedelta(milliseconds=i * 10)
self.bscan_queue.put((col, col_time, DATA_TYPE_FOURIER)) 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 bscan_col = None
if add_to_bscan and bscan_col is not None and data_type != DATA_TYPE_FOURIER: if add_to_bscan and bscan_col is not None and data_type != DATA_TYPE_FOURIER:
@ -1168,18 +1377,35 @@ class DataAnalyzerApp:
def process_files(self): def process_files(self):
"""Обработка файлов в цикле.""" """Обработка файлов в цикле."""
files = sorted([f for f in os.listdir() if f.endswith('.csv') or files = sorted([
f.endswith('.txt1') or f.endswith('.txt2') or f.endswith('.csv')]) 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] 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: for fname in new_files:
time_start = time.perf_counter() time_start = time.perf_counter()
try: 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) data_type, A = load_data_with_type(fname)
# Поддержка списка сегментов (HEX с FE)
if isinstance(A, list): if isinstance(A, list):
original_size = len(A[0]) if len(A) > 0 else 0 original_size = len(A[0]) if len(A) > 0 else 0
elif isinstance(A, np.ndarray): elif isinstance(A, np.ndarray):
@ -1187,6 +1413,47 @@ class DataAnalyzerApp:
else: else:
original_size = 0 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): 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] timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
@ -1217,15 +1484,94 @@ class DataAnalyzerApp:
self.processed_files.add(fname) self.processed_files.add(fname)
self.do_pending_update() 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) 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 __name__ == "__main__":
if len(argv) == 2:
data_dir = argv[1]
print("data dir:", data_dir)
root = tk.Tk() root = tk.Tk()
app = DataAnalyzerApp(root) app = DataAnalyzerApp(root)
app.run() app.run()