From 4b78c2808df42d7c4f85960d074e47cd822ed7ba Mon Sep 17 00:00:00 2001 From: Ayzen Date: Wed, 1 Apr 2026 22:05:04 +0300 Subject: [PATCH] added gpr speed tracking --- GPR_motion_algo.py | 787 ++++++++++++++++++ .../common_cpp/ipc/include/shared_types.hpp | 2 + .../common_cpp/ipc/src/shared_types.cpp | 17 + .../src/data_preprocessor.cpp | 2 + .../include/processing_live_config.hpp | 2 + .../src/processing_live_config.cpp | 12 + .../processors/src/gpr_processor.cpp | 263 +++++- .../src/sweep_orchestrator.cpp | 6 + .../controllers/app_window_config_mixin.py | 45 +- .../controllers/app_window_pipeline_mixin.py | 2 +- .../sections/processing_section.py | 16 + python_app/models/dataset_model.py | 2 + python_app/models/gui_profile_codec.py | 15 +- python_app/models/gui_profile_schema.py | 2 + .../orchestration/live_processing_config.py | 4 + python_app/orchestration/shm/binary_cursor.py | 4 + python_app/orchestration/shm/decoder.py | 16 +- python_app/storage/npz/serialize.py | 7 + python_app/storage/npz/snapshot_numpy.py | 4 + python_app/storage/npz/store.py | 4 + 20 files changed, 1165 insertions(+), 47 deletions(-) create mode 100644 GPR_motion_algo.py diff --git a/GPR_motion_algo.py b/GPR_motion_algo.py new file mode 100644 index 0000000..58a3e39 --- /dev/null +++ b/GPR_motion_algo.py @@ -0,0 +1,787 @@ +""" +MIMO GPR — локализация через пересечение эллипсов +================================================== + +Физика в двух словах: + Пик A-скана пары (Tx_i, Rx_j) на задержке τ означает: + |Tx → объект| + |объект → Rx| = v · τ + Это уравнение эллипса. Истинный отражатель лежит на + пересечении всех 16 эллипсов (по одному на пару). + +Алгоритм: + 1. S(f) → IFFT → 16 A-сканов + 2. Поиск пиков: SNR = пик / медиана > порог + 3. Для каждого пика → мягкий эллипс в аккумуляторе + (с компенсацией геометрического и углового затухания) + 4. CLEAN: найти максимум → убрать его эллипсы → повторить + +О параметре SHELL_SIGMA: + Аккумулятор — это «мягкое голосование». Каждый эллипс добавляет + не единицу, а гауссово-взвешенный вклад: + w = exp(−δ²/2σ²), где δ = |R_Tx + R_Rx − v·τ| + SHELL_SIGMA — ширина этой гауссовой оболочки. + Слишком широко → ghost-цели не подавляются. + Слишком узко → вклад падает до нуля из-за дискретности сетки. + Оптимум: ~ 0.4 × δZ, где δZ = v/(2B) — разрешение по глубине. + +О score: + score = количество пар (из 16), чей эллипс проходит + через данную точку с невязкой δ < 3σ. + Принимает целые значения от 0 до 16. + Максимальный score у истинного объекта = 16 (все пары согласны). + Ghost-цели имеют меньший score, т.к. согласуются только + с частью пар. + +О компенсации затухания: + При генерации S(f) сигнал ослаблен: + geo(i,j) = 1/(R_Tx · R_Rx) — геометрическое ослабление + pat(i,j) = cos²(θ_Tx)·cos²(θ_Rx) — диаграмма направленности + Без компенсации глубокий/угловой отражатель будет недооценён. + Компенсация: делим вес каждого пика на ожидаемое затухание + в точке z_apparent, вычисленное для данной пары антенн. + +Геометрия: плоскость XZ (X — вдоль антенн, Z — глубина). +""" + +""" +MIMO GPR — локализация через пересечение эллипсов +================================================== +Версия для реальных данных +""" + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from scipy.signal import find_peaks +from scipy.ndimage import gaussian_filter, label +from pathlib import Path +from dataclasses import dataclass, field +from typing import Dict, List, Tuple + +### Изменяемые параметры ====== +INPUT_IDX = [0,1,2,3] +OUTPUT_IDX = [0,3] + +MIN_DEPTH = 3.0 # [м] пропустить прямую волну +MAX_DEPTH = 20.0 +COMP_POWER = 0.22 # степень компенсации затухания + +# Обрезка по частоте +F_START = 28*1e8 # Нижняя частота +F_STOP = 60*1e8 # Верхняя частота + +# Параметры скорости в Motion Config +SPEED_M_S = 0.48 # Скорость м/с +LOOK_ANGLE_DEG = 2.0 # Угол наклона радара отн-но горизонтали (град) + +# Вычитание среднего фона (background removal) +# True → вычитать среднее по всем снимкам в папке (убирает прямую волну и статичные отражения) +# False → использовать данные как есть +BG_SUBTRACT = True +BG_PATH = Path('10m_cyl_motion_21sec_0.48msec_27032026_2.8-6ghz_751/preprocessed') + +DATA_PATH = Path('10m_cyl_motion_21sec_0.48msec_27032026_2.8-6ghz_751/preprocessed/0002_id3_ns26032413993535') # <-- УКАЖИТЕ ПУТЬ + +# =============================== + +@dataclass +class MotionConfig: + """ + Конфигурация движения для одного кадра из 8 пар. + + speed_m_s: + Линейная скорость движения радара. + + look_angle_deg: + Угол между направлением движения и осью дальности Z. + Если движение почти "вдоль дальности", ставьте угол близкий к 0°. + Тогда dz = v_move * dt * cos(angle) ≈ v_move * dt. + + sweep_time_s: + Время прохода по всем частотам для одной пары. + + switch_time_s: + Время переключения между соседними парами. + + pair_order_phys: + Реальный порядок измерения в физических индексах: + [(tx_phys_1, rx_phys_1), (tx_phys_2, rx_phys_2), ...] + + reference_mode: + Относительно какого момента считаем dt: + - 'frame_center' : середина всего цикла по 8 парам + - 'first_pair' : центр первой пары + + direction_sign: + Знак движения по оси дальности. + +1 -> более поздние пары выглядят глубже + -1 -> более поздние пары выглядят ближе + """ + speed_m_s: float = 0.50 + look_angle_deg: float = 0.0 + sweep_time_s: float = 0.040 + switch_time_s: float = 0.005 + pair_order_phys: List[Tuple[int, int]] = field(default_factory=lambda: [ + (tx_phys, rx_phys) + for tx_phys in sorted(OUTPUT_IDX) + for rx_phys in sorted(INPUT_IDX) + ]) + reference_mode: str = 'frame_center' + direction_sign: float = +1.0 + +# Конфигурация движения +MOTION_CONFIG = MotionConfig( + speed_m_s=SPEED_M_S, + look_angle_deg=LOOK_ANGLE_DEG, + sweep_time_s=0.15, # ref 0.15 + switch_time_s=1e-5, + pair_order_phys=[ + (0, 0), (0, 1), (0, 2), (0, 3), # Этот порядок текущий, возможно в будущем что-то поменяется + (3, 0), (3, 1), (3, 2), (3, 3), + ], + reference_mode='frame_center', + direction_sign=+1.0, +) + +### Список параметров и констант использующиеся в коде: ### + + +MODE = 'point' # Один из 2х режимов `point` or `extended` + +eps_r = 1.0 # Диэлектрическая проницаемость среды +v = 3e8 / np.sqrt(eps_r) # скорость света в среде + +# ══════════════════════════════════════════════════════ +# КООРДИНАТЫ АНТЕНН — УКАЖИТЕ РЕАЛЬНЫЕ ЗНАЧЕНИЯ! +# ══════════════════════════════════════════════════════ +# Координаты вдоль оси X на поверхности (z = 0), в метрах + +# Физические координаты антенн по их реальным индексам +TX_POSITIONS = {0: 90.5*0.01, # Tx с индексом 0 → x = +0.9 м + 3: -90.5*0.01} # Tx с индексом 3 → x = -0.9 м + +RX_POSITIONS = {0: -18*0.01, + 1: 48.5*0.01, + 2: -49.0*0.01, + 3: 18.5*0.01} + +# Массивы позиций в порядке возрастания физических индексов +# (нужны для сетки аккумулятора и графиков) +x_tx = np.array([TX_POSITIONS[i] for i in sorted(TX_POSITIONS)]) +x_rx = np.array([RX_POSITIONS[j] for j in sorted(RX_POSITIONS)]) + +# Параметры алгоритма +SNR_THRESH = 4.5 # минимальный SNR пика +SNR_COMP_MAX = 25.0 # Верхний порог для компенсированного значения SNR + + +# Параметр: максимальное число объектов для поиска +MAX_OBJECTS = 15 # <-- настройте под вашу задачу + +# Границы сетки аккумулятора +x_min, x_max = x_tx.min() - 2.0, x_tx.max() + 2.0 # [м] +z_min, z_max = 0.2, MAX_DEPTH # <-- глубина [м] + + + +# ══════════════════════════════════════════════════════ +# 1. ЗАГРУЗКА РЕАЛЬНЫХ ДАННЫХ +# ══════════════════════════════════════════════════════ + +def load_mimo_data(data_path, input_idx, output_idx): + data_path = Path(data_path) + s21_data = {} + freq_data = {} + + for f in data_path.glob("i*_o*_s21.npy"): + name = f.stem + parts = name.split('_') + i_tx_phys = int(parts[1][1:]) + i_rx_phys = int(parts[0][1:]) + + if i_tx_phys not in output_idx or i_rx_phys not in input_idx: + continue + + # Переводим физический индекс → порядковый (0,1,2,...) + i_tx = sorted(output_idx).index(i_tx_phys) + i_rx = sorted(input_idx).index(i_rx_phys) + + s21_data[(i_tx, i_rx)] = np.load(f) + freq_file = data_path / f"i{i_rx_phys}_o{i_tx_phys}_freq.npy" + freq_data[(i_tx, i_rx)] = np.load(freq_file) + + tx_indices = sorted(set(k[0] for k in s21_data)) + rx_indices = sorted(set(k[1] for k in s21_data)) + n_tx = len(tx_indices) + n_rx = len(rx_indices) + + print(f"Загружено пар: {len(s21_data)}") + print(f"Передатчиков: {n_tx}, Приёмников: {n_rx}") + + return s21_data, freq_data, n_tx, n_rx + + +# Загрузка данных +s21_data, freq_data, N_tx, N_rx = load_mimo_data(DATA_PATH, INPUT_IDX, OUTPUT_IDX) +N_pairs = len(s21_data) + +# ══════════════════════════════════════════════════════ +# 1б. ВЫЧИСЛЕНИЕ СРЕДНЕГО ФОНА ПО ВСЕМ СНИМКАМ +# ══════════════════════════════════════════════════════ + +def compute_background(bg_path, input_idx, output_idx): + """ + Для каждой пары (i_tx, i_rx) усредняем S21 по всем снимкам в папке. + + Возвращает: + bg : dict[(i_tx, i_rx)] → np.array (complex), усреднённый S21 + """ + bg_path = Path(bg_path) + snapshots = sorted(bg_path.glob("*/")) # каждый подкаталог — один снимок + snapshots = [s for s in snapshots if s.is_dir()] + + if len(snapshots) == 0: + print("⚠️ Снимков для фона не найдено, BG_SUBTRACT отключён.") + return None + + print(f"Вычисление фона по {len(snapshots)} снимкам...", end=" ", flush=True) + + # Накопитель: для каждой пары суммируем S21 + bg_sum = {} + bg_count = {} + + for snap_dir in snapshots: + for f in snap_dir.glob("i*_o*_s21.npy"): + name = f.stem + parts = name.split('_') + i_tx_phys = int(parts[1][1:]) + i_rx_phys = int(parts[0][1:]) + + if i_tx_phys not in output_idx or i_rx_phys not in input_idx: + continue + + i_tx = sorted(output_idx).index(i_tx_phys) + i_rx = sorted(input_idx).index(i_rx_phys) + key = (i_tx, i_rx) + + s21 = np.load(f) + if key not in bg_sum: + bg_sum[key] = np.zeros_like(s21, dtype=complex) + bg_count[key] = 0 + bg_sum[key] += s21 + bg_count[key] += 1 + + bg = {key: bg_sum[key] / bg_count[key] for key in bg_sum} + print(f"готово. Пар: {len(bg)}, снимков на пару: " + f"{list(bg_count.values())[0] if bg_count else 0}") + return bg + + +if BG_SUBTRACT: + background = compute_background(BG_PATH, INPUT_IDX, OUTPUT_IDX) + if background is None: + BG_SUBTRACT = False # автоматически выключаем если нет данных +else: + background = None + print("BG_SUBTRACT = False, вычитание фона отключено.") + + +# Проверка частот (берём из первой пары как референс) +first_key = list(freq_data.keys())[0] +freqs = freq_data[first_key] + + + +# Проверим что частоты одинаковые для всех пар +for key, freq in freq_data.items(): + if not np.allclose(freq, freqs): + print(f"⚠️ Частоты для пары {key} отличаются!") + + +mask_freq = (freqs >= F_START) & (freqs <= F_STOP) + +freqs = freqs[mask_freq] + +f_min, f_max = freqs[0], freqs[-1] +BW = f_max - f_min +N_f = len(freqs) + + +# ══════════════════════════════════════════════════════ +# 2. ПАРАМЕТРЫ СИСТЕМЫ +# ══════════════════════════════════════════════════════ + +# Проверка соответствия координатов антенн +assert len(x_tx) == N_tx, f"x_tx должен содержать {N_tx} элементов" +assert len(x_rx) == N_rx, f"x_rx должен содержать {N_rx} элементов" + + +# SHELL_SIGMA — ширина гауссовой оболочки +SHELL_SIGMA = v / BW * 0.5 # [м] + + +# Сетка аккумулятора + +x_grid = np.linspace(x_min, x_max, 300) +z_grid = np.linspace(z_min, z_max, 300) +XX, ZZ = np.meshgrid(x_grid, z_grid) + +# Расстояния от сетки до каждой антенны +R_tx_grid = {i: np.sqrt((XX - x_tx[i])**2 + ZZ**2) for i in range(N_tx)} +R_rx_grid = {j: np.sqrt((XX - x_rx[j])**2 + ZZ**2) for j in range(N_rx)} + + +# ══════════════════════════════════════════════════════ +# 3. ВЫЧИСЛЕНИЕ A-СКАНОВ ИЗ РЕАЛЬНЫХ S21 +# ══════════════════════════════════════════════════════ + +def compute_ascan(s21, freq, f_start, f_stop, window=True): + """ + S21(f) → IFFT → A-скан с правильным частотным сдвигом. + + Проблема наивного подхода (buf[:n] = s21): + IFFT считает, что спектр начинается с 0 Гц. + Реальные данные начинаются с f[0] > 0, поэтому + нулевая задержка смещается и в A-скане появляются биения. + + Правильный подход — сдвиг спектра: + Шаг частотной сетки df вычисляется из данных. + Индекс первой частоты: k0 = round(f[0] / df). + Данные кладутся в H[k0 : k0+n], а не в H[0 : n]. + Тогда IFFT корректно восстанавливает временной сигнал + с нулевой задержкой в t=0. + + Размер FFT: + Минимум для покрытия всего диапазона [0, f[-1]]: + min_len = 2 * (k0 + n - 1) + Округляем вверх до степени двойки для скорости FFT. + """ + mask_freq_ = (freq >= f_start) & (freq <= f_stop) + freq = freq[mask_freq_] + s21 = s21[mask_freq_] + + n = len(freq) + if n < 2: + raise ValueError("Слишком мало частотных точек") + + # Шаг частотной сетки + df = (freq[-1] - freq[0]) / (n - 1) + if df <= 0: + raise ValueError("Частоты не возрастают") + + # Индекс первой частоты в полной сетке от 0 до f[-1] + k0 = int(np.round(freq[0] / df)) + + # Минимальный размер FFT, округлённый до степени двойки + min_len = 2 * (k0 + n - 1) + n_fft = 1 << int(np.ceil(np.log2(min_len))) + + + # Временна́я ось — пересчитываем из нового n_fft + dt = 1.0 / (n_fft * df) + t_sec = np.arange(n_fft, dtype=float) * dt + + # Оконная функция (подавление боковых лепестков IFFT) + s = s21 * np.hanning(n) if window else s21.copy() + + # Спектр со сдвигом: данные на своём месте в частотной сетке + H = np.zeros(n_fft, dtype=np.complex128) + H[k0 : k0 + n] = s + + y = np.abs(np.fft.ifft(H)) + + + return t_sec[:y.size], y[:y.size] + + +print("Вычисление A-сканов из реальных данных...", end=" ", flush=True) + +A = {} # A[(i,j)] — амплитудный A-скан +T_h = {} # T_h[(i,j)] — временна́я ось для этой пары [с] +Z_h = {} # Z_h[(i,j)] — ось глубины [м] + +for (i, j), s21 in s21_data.items(): + s21_proc = s21.copy() + + # Вычитание фона в частотной области + if BG_SUBTRACT and background is not None and (i, j) in background: + s21_proc = s21_proc - background[(i, j)] + # Примечание: вычитаем до обрезки по частоте и до окна — + # фон вычисляется из полных (необрезанных) данных, + # поэтому вычитание корректно в полном частотном диапазоне. + + t_pair, a_pair = compute_ascan(s21_proc, freq_data[(i, j)], + f_start=F_START, f_stop=F_STOP) + T_h[(i, j)] = t_pair + Z_h[(i, j)] = t_pair * v / 2 + A[(i, j)] = a_pair + +bg_label = "с вычитанием фона" if BG_SUBTRACT else "без вычитания фона" +print(f"готово ({bg_label}).") + + +# Общая ось z для визуализации и поиска пиков +# (берём максимальный диапазон по всем парам) +z_h = Z_h[list(Z_h.keys())[0]] # все пары дают одинаковую ось, если freq совпадают +t_h = T_h[list(T_h.keys())[0]] + + +# ══════════════════════════════════════════════════════ +# 4. ДЕТЕКТИРОВАНИЕ ПИКОВ +# ══════════════════════════════════════════════════════ + +def attenuation_at_depth(i_tx, i_rx, z_app): + """ + Ожидаемое ослабление geo·pattern для точки прямо под виртуальным + центром пары на глубине z_app. + + Используется для компенсации: реальный SNR пика делится на это + значение, чтобы вес глубокого/углового объекта не занижался. + """ + xc = (x_tx[i_tx] + x_rx[i_rx]) / 2.0 # виртуальный центр + Rtx = np.sqrt((xc - x_tx[i_tx])**2 + z_app**2) + Rrx = np.sqrt((xc - x_rx[i_rx])**2 + z_app**2) + geo = 1.0 / (Rtx * Rrx + 1e-12) + pat = (z_app / (Rtx + 1e-12))**2 * (z_app / (Rrx + 1e-12))**2 + return geo * pat + 1e-30 # +ε чтобы не делить на ноль + + +def find_peaks_snr(i_tx, i_rx, SNR_COMP_MAX = SNR_COMP_MAX): + """ + Поиск пиков A-скана. + Возвращает список dict: + z_app — кажущаяся глубина [м] + tau — задержка [с] + snr_raw — SNR без компенсации = пик / медиана + snr_comp— SNR с компенсацией ослабления (используется в аккумуляторе) + """ + ascan = A[(i_tx, i_rx)] + z_h_ij = Z_h[(i_tx, i_rx)] + t_h_ij = T_h[(i_tx, i_rx)] + i_min = np.searchsorted(z_h_ij, MIN_DEPTH) + i_max = np.searchsorted(z_h_ij, MAX_DEPTH) + + noise = np.median(ascan[i_min:i_max]) # Добавил чтобы было удобно считать SNR отнсительно выбранной области + min_dist = max(4, int(v / (2*BW) / (z_h_ij[1] - z_h_ij[0]) * 0.7)) + idx, _ = find_peaks(ascan[i_min:i_max], + height=noise * SNR_THRESH, + distance=min_dist) + idx += i_min + result = [] + for p in idx: + z_app = float(z_h_ij[p]) + snr_raw = float(ascan[p] / noise) + atten = attenuation_at_depth(i_tx, i_rx, z_app) + atten_norm = atten / attenuation_at_depth(i_tx, i_rx, 3.0) # Референсная глубина - 3м + snr_comp = snr_raw / (atten_norm ** COMP_POWER + 1e-12) + snr_comp = min(snr_comp, SNR_COMP_MAX) # ← clipping + result.append({'z_app': z_app, + 'tau': float(t_h_ij[p]), + 'snr_raw': snr_raw, + 'snr_comp': snr_comp}) + return result + + +peaks = {(i, j): find_peaks_snr(i, j) + for i in range(N_tx) for j in range(N_rx)} + +# ══════════════════════════════════════════════════════ +# 6. ПОИСК ОБЪЕКТОВ +# ══════════════════════════════════════════════════════ + +def find_centroid(acc_s, iz, ix, rpz, rpx): + """ + Взвешенный центроид аккумулятора в окрестности (iz, ix). + """ + NZ, NX = acc_s.shape + iz0 = max(0, iz - rpz); iz1 = min(NZ, iz + rpz) + ix0 = max(0, ix - rpx); ix1 = min(NX, ix + rpx) + patch = acc_s[iz0:iz1, ix0:ix1].copy() + W = patch.sum() + if W <= 0: + return x_grid[ix], z_grid[iz] + rows = np.arange(iz0, iz1)[:, None] * np.ones(patch.shape) + cols = np.ones(patch.shape) * np.arange(ix0, ix1)[None, :] + iz_c = int(round(np.clip((rows * patch).sum() / W, 0, NZ-1))) + ix_c = int(round(np.clip((cols * patch).sum() / W, 0, NX-1))) + return x_grid[ix_c], z_grid[iz_c] + + +# ══════════════════════════════════════════════════════ +# 8. MOTION-AWARE FIRST-ORDER CORRECTION +# ══════════════════════════════════════════════════════ + +""" +Первый блок для движения без изменения продакшн-пайплайна выше. + +Идея: +1. Для каждой пары (Tx, Rx) задаём время центра её измерения. +2. По известной скорости и углу получаем сдвиг по дальности dz. +3. Переводим dz в поправку по задержке dtau. +4. Для уже найденных пиков формируем motion-corrected версию: + tau_corr, z_corr + +Это first-order модель: считаем, что вся пара измерена в момент времени t_center. +Если позже окажется, что смещение за один sweep пары уже заметно, +следующим шагом надо будет делать per-frequency коррекцию до IFFT. +""" + + +def _build_phys_to_logical_maps(output_idx, input_idx): + tx_map = {phys: k for k, phys in enumerate(sorted(output_idx))} + rx_map = {phys: k for k, phys in enumerate(sorted(input_idx))} + return tx_map, rx_map + + + +def compute_pair_timestamps(config: MotionConfig, + output_idx=OUTPUT_IDX, + input_idx=INPUT_IDX) -> Tuple[Dict[Tuple[int, int], Dict], List[Dict]]: + """ + Для каждой пары возвращает: + t_start, t_center, dt_ref, dz_motion, dtau_motion + + Ключи словаря pair_timestamps — логические индексы (i_tx, i_rx), + совместимые с существующим словарём peaks. + """ + tx_phys_to_log, rx_phys_to_log = _build_phys_to_logical_maps(output_idx, input_idx) + + rows: List[Dict] = [] + t_cursor = 0.0 + + for order_idx, (tx_phys, rx_phys) in enumerate(config.pair_order_phys): + if tx_phys not in tx_phys_to_log: + raise ValueError(f"Tx {tx_phys} отсутствует в OUTPUT_IDX={output_idx}") + if rx_phys not in rx_phys_to_log: + raise ValueError(f"Rx {rx_phys} отсутствует в INPUT_IDX={input_idx}") + + i_tx = tx_phys_to_log[tx_phys] + i_rx = rx_phys_to_log[rx_phys] + + t_start = t_cursor + t_center = t_start + 0.5 * config.sweep_time_s + t_stop = t_start + config.sweep_time_s + + rows.append({ + 'order_idx': order_idx, + 'tx_phys': tx_phys, + 'rx_phys': rx_phys, + 'i_tx': i_tx, + 'i_rx': i_rx, + 't_start_s': t_start, + 't_center_s': t_center, + 't_stop_s': t_stop, + }) + + t_cursor = t_stop + config.switch_time_s + + if not rows: + return {}, [] + + if config.reference_mode == 'frame_center': + t_ref = 0.5 * (rows[0]['t_center_s'] + rows[-1]['t_center_s']) + elif config.reference_mode == 'first_pair': + t_ref = rows[0]['t_center_s'] + else: + raise ValueError("reference_mode must be 'frame_center' or 'first_pair'") + + cos_theta = np.cos(np.radians(config.look_angle_deg)) + pair_timestamps: Dict[Tuple[int, int], Dict] = {} + + for row in rows: + dt_ref = row['t_center_s'] - t_ref + dz_motion = config.direction_sign * config.speed_m_s * dt_ref * cos_theta + dtau_motion = 2.0 * dz_motion / v + + row['dt_ref_s'] = dt_ref + row['dz_motion_m'] = dz_motion + row['dtau_motion_s'] = dtau_motion + + pair_timestamps[(row['i_tx'], row['i_rx'])] = row.copy() + + return pair_timestamps, rows + + + +def build_corrected_peaks(peaks_in: Dict[Tuple[int, int], List[Dict]], + pair_timestamps: Dict[Tuple[int, int], Dict]) -> Dict[Tuple[int, int], List[Dict]]: + """ + Формирует словарь corrected_peaks с motion-aware поправками. + + Для каждого пика добавляет: + tau_raw, z_app_raw + tau_corr, z_corr + dz_motion, dtau_motion + + Важно: + z_corr = v * tau_corr / 2 + потому что ось z_app в текущем пайплайне — это apparent depth. + """ + corrected = {} + + for key, peak_list in peaks_in.items(): + if key not in pair_timestamps: + raise KeyError(f"Нет временной информации для пары {key}") + + info = pair_timestamps[key] + dz_motion = info['dz_motion_m'] + dtau_motion = info['dtau_motion_s'] + + corrected_list = [] + for pk in peak_list: + tau_raw = float(pk['tau']) + z_raw = float(pk['z_app']) + # dz_motion here is defined as a correction to the apparent depth itself: + # z_corr = z_raw + dz_motion. + # Therefore tau must be corrected with the same sign. + tau_corr = tau_raw + dtau_motion + z_corr = 0.5 * v * tau_corr + + pk_corr = dict(pk) + pk_corr.update({ + 'tau_raw': tau_raw, + 'z_app_raw': z_raw, + 'tau_corr': tau_corr, + 'z_corr': z_corr, + 'dz_motion': dz_motion, + 'dtau_motion': dtau_motion, + }) + corrected_list.append(pk_corr) + + corrected[key] = corrected_list + + return corrected + + + +pair_timestamps, pair_timing_rows = compute_pair_timestamps(MOTION_CONFIG) +corrected_peaks = build_corrected_peaks(peaks, pair_timestamps) + + + +# ══════════════════════════════════════════════════════ +# 9. MOTION-AWARE IMAGE BUILD FROM CORRECTED PEAKS +# ══════════════════════════════════════════════════════ + +""" +Эта ячейка строит motion-aware картинку, используя corrected_peaks из блока выше. + +Что меняется относительно статического продакшн-пайплайна: +- в аккумуляторе используется tau_corr вместо tau +- в apparent-depth логике CLEAN используется z_corr вместо z_app +- score считается по corrected пикам + +Исходные A-сканы остаются теми же, но на графике ниже можно показывать уже +motion-corrected положения пиков. +""" + + +def _peak_in_work_depth(pk): + return MIN_DEPTH <= pk['z_corr'] <= MAX_DEPTH + + + +def build_accumulator_motion(corrected_peaks_in, exclude_z_ranges): + acc = np.zeros_like(XX) + for i in range(N_tx): + for j in range(N_rx): + for pk in corrected_peaks_in[(i, j)]: + if not _peak_in_work_depth(pk): + continue + if any(lo <= pk['z_corr'] <= hi for lo, hi in exclude_z_ranges): + continue + r_total = v * pk['tau_corr'] + residual = R_tx_grid[i] + R_rx_grid[j] - r_total + shell = np.exp(-0.5 * (residual / SHELL_SIGMA)**2) + acc += shell * pk['snr_comp'] + return acc + + + +def count_agreeing_ellipses_motion(x_est, z_est, corrected_peaks_in, exclude_z_ranges): + count = 0 + for i in range(N_tx): + for j in range(N_rx): + for pk in corrected_peaks_in[(i, j)]: + if not _peak_in_work_depth(pk): + continue + if any(lo <= pk['z_corr'] <= hi for lo, hi in exclude_z_ranges): + continue + Rt = np.sqrt((x_est - x_tx[i])**2 + z_est**2) + Rr = np.sqrt((x_est - x_rx[j])**2 + z_est**2) + if abs(Rt + Rr - v * pk['tau_corr']) < SHELL_SIGMA * 6: + count += 1 + break + return count + + + +def clean_find_motion(corrected_peaks_in, n_search=10, suppress_r_cm=7, thresh_frac=0.05): + dx = x_grid[1] - x_grid[0] + dz = z_grid[1] - z_grid[0] + rpx = int(suppress_r_cm / 100 / dx) + rpz = int(suppress_r_cm / 100 / dz) + + excl_z = [] + found_motion = [] + acc_initial = build_accumulator_motion(corrected_peaks_in, []) + + for step in range(n_search): + acc = build_accumulator_motion(corrected_peaks_in, excl_z) + acc_s = gaussian_filter(acc, sigma=3) + + if acc_s.max() < thresh_frac * acc_initial.max(): + break + + iz, ix = np.unravel_index(acc_s.argmax(), acc_s.shape) + x_est, z_est = find_centroid(acc_s, iz, ix, rpz, rpx) + score = count_agreeing_ellipses_motion(x_est, z_est, corrected_peaks_in, excl_z) + found_motion.append({'x': x_est, 'z': z_est, 'score': score}) + + matched = [pk['z_corr'] + for i in range(N_tx) for j in range(N_rx) + for pk in corrected_peaks_in[(i, j)] + if _peak_in_work_depth(pk) + and not any(lo <= pk['z_corr'] <= hi for lo, hi in excl_z) + and abs(np.sqrt((x_est - x_tx[i])**2 + z_est**2) + + np.sqrt((x_est - x_rx[j])**2 + z_est**2) - + v * pk['tau_corr']) < SHELL_SIGMA * 3] + + if matched: + margin = SHELL_SIGMA * 1.0 + excl_z.append((min(matched) - margin, max(matched) + margin)) + + return found_motion, acc_initial + + +if MODE == 'point': + found_motion, accum_motion = clean_find_motion(corrected_peaks, n_search=MAX_OBJECTS) + + +# ─── График 2: motion-aware карта накопления ─────────────────────── +fig, ax = plt.subplots(figsize=(12, 7)) +acc_motion_s = gaussian_filter(accum_motion, sigma=3) +im = ax.imshow( + acc_motion_s, + extent=[x_grid[0]*100, x_grid[-1]*100, z_grid[-1]*100, z_grid[0]*100], + aspect='auto', origin='upper', cmap='hot', + vmin=acc_motion_s.max()*0.45, vmax=acc_motion_s.max()*0.95, +) +plt.colorbar(im, ax=ax, label='Накопленный вес (motion-aware)') +ax.plot(x_tx*100, np.zeros(N_tx), 'r^', ms=10, label='Tx', zorder=5) +ax.plot(x_rx*100, np.zeros(N_rx), 'bv', ms=10, label='Rx', zorder=5) +for obj in found_motion: + lbl = f"score={obj['score']}/{N_pairs}" + ax.plot(obj['x']*100, obj['z']*100, 'wD', ms=9, zorder=11, markeredgecolor='black', mew=1.2) + ax.annotate(lbl, (obj['x']*100, obj['z']*100), textcoords='offset points', xytext=(6, 4), + fontsize=8, color='white', bbox=dict(boxstyle='round,pad=0.2', fc='black', alpha=0.5)) +ax.plot([], [], 'wD', ms=9, markeredgecolor='k', mew=1.2, label='Найденные объекты') +ax.set_xlabel('X [см]') +ax.set_ylabel('Глубина Z [см]') +ax.set_title('Motion-aware карта накопления эллипсов') +ax.set_xlim(x_grid[0]*100, x_grid[-1]*100) +ax.set_ylim(z_grid[-1]*100, z_grid[0]*100) +ax.legend(loc='lower right', fontsize=9) +ax.grid(alpha=0.25) +ax.invert_yaxis() +plt.tight_layout() \ No newline at end of file diff --git a/data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp b/data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp index 4553715..e78033c 100644 --- a/data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp +++ b/data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp @@ -43,6 +43,8 @@ struct SweepTraceBlock { struct RawSweepCollection { std::uint64_t collection_id = 0; std::uint64_t monotonic_ns = 0; + std::uint64_t capture_start_ns = 0; + std::uint64_t capture_end_ns = 0; std::vector traces{}; }; diff --git a/data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp b/data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp index 7f94a31..0b81f01 100644 --- a/data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp +++ b/data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp @@ -89,6 +89,10 @@ class BinaryReader { return offset_ == bytes_.size(); } + [[nodiscard]] auto remaining_bytes() const -> std::size_t { + return bytes_.size() - offset_; + } + private: void ensure_available(std::size_t size) const { if (offset_ + size > bytes_.size()) { @@ -163,6 +167,9 @@ void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const Raw for (const auto& trace : collection.traces) { write_trace_block(writer, trace); } + + writer.write(collection.capture_start_ns); + writer.write(collection.capture_end_ns); } [[nodiscard]] auto read_trace_collection(BinaryReader& reader, std::uint32_t expected_magic) -> RawSweepCollection { @@ -181,6 +188,16 @@ void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const Raw collection.traces.push_back(read_trace_block(reader)); } + if (reader.remaining_bytes() == 0U) { + return collection; + } + if (reader.remaining_bytes() != (sizeof(std::uint64_t) * 2U)) { + throw std::runtime_error("Unexpected trailing bytes in trace collection"); + } + + collection.capture_start_ns = reader.read(); + collection.capture_end_ns = reader.read(); + return collection; } diff --git a/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp b/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp index 9b2c05a..959fbe3 100644 --- a/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp +++ b/data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp @@ -70,6 +70,8 @@ auto DataPreprocessor::preprocess_collection(const ipc::RawSweepCollection& raw_ ipc::PreprocessedCollection preprocessed{}; preprocessed.collection_id = raw_collection.collection_id; preprocessed.monotonic_ns = ipc::current_monotonic_ns(); + preprocessed.capture_start_ns = raw_collection.capture_start_ns; + preprocessed.capture_end_ns = raw_collection.capture_end_ns; preprocessed.traces.reserve(raw_collection.traces.size()); for (const auto& raw_trace : raw_collection.traces) { diff --git a/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp b/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp index 2c47435..21737e6 100644 --- a/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp +++ b/data_acq_and_processing/processing/data_processor/include/processing_live_config.hpp @@ -34,6 +34,8 @@ struct ProcessingLiveConfig { float gpr_comp_power = 0.2F; float gpr_start_freq_mhz = 3000.0F; float gpr_stop_freq_mhz = 6000.0F; + float gpr_speed_m_s = 0.0F; + float gpr_look_angle_deg = 0.0F; bool gpr_background_subtract_enabled = true; std::uint32_t gpr_background_mean_count = 10U; std::uint64_t history_command_seq = 0; diff --git a/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp b/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp index a8966e6..988aa8d 100644 --- a/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp +++ b/data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp @@ -199,6 +199,18 @@ using Json = nlohmann::json; } config.gpr_stop_freq_mhz = static_cast(found->get()); } + if (const auto found = root.find("gpr_speed_m_s"); found != root.end()) { + if (!found->is_number()) { + throw std::runtime_error("processing.gpr_speed_m_s must be number"); + } + config.gpr_speed_m_s = static_cast(found->get()); + } + if (const auto found = root.find("gpr_look_angle_deg"); found != root.end()) { + if (!found->is_number()) { + throw std::runtime_error("processing.gpr_look_angle_deg must be number"); + } + config.gpr_look_angle_deg = static_cast(found->get()); + } if (const auto found = root.find("gpr_background_subtract_enabled"); found != root.end()) { if (!found->is_boolean()) { throw std::runtime_error("processing.gpr_background_subtract_enabled must be bool"); diff --git a/data_acq_and_processing/processing/processors/src/gpr_processor.cpp b/data_acq_and_processing/processing/processors/src/gpr_processor.cpp index 7bf0e3f..e8ec958 100644 --- a/data_acq_and_processing/processing/processors/src/gpr_processor.cpp +++ b/data_acq_and_processing/processing/processors/src/gpr_processor.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -18,9 +19,9 @@ namespace { constexpr double kPi = 3.14159265358979323846; constexpr double kSpeedOfLightMetersPerSec = 299'792'458.0; constexpr double kAccumulatorXMarginM = 2.0; -constexpr double kAccumulatorZMinM = 0.10; -constexpr double kSnrThresh = 3.0; -constexpr double kSnrCompMax = 20.0; +constexpr double kAccumulatorZMinM = 0.20; +constexpr double kSnrThresh = 4.5; +constexpr double kSnrCompMax = 25.0; constexpr std::size_t kGridWidth = 300U; constexpr std::size_t kGridHeight = 300U; constexpr double kGaussianSigma = 3.0; @@ -33,6 +34,10 @@ constexpr double kExtendedMinAreaCm2 = 2.0; using PairKey = std::uint64_t; struct SelectedTrace { + ipc::ComboKey combo{}; + std::uint32_t tx_local_index = 0U; + std::uint32_t rx_local_index = 0U; + std::size_t run_order = 0U; std::vector frequency_hz{}; std::vector> s21{}; }; @@ -47,6 +52,8 @@ struct AscanResult { struct PeakRecord { double z_app = 0.0; double tau = 0.0; + double tau_corr = 0.0; + double z_corr = 0.0; double snr_raw = 0.0; double snr_comp = 0.0; }; @@ -79,6 +86,12 @@ struct BackgroundAccumulator { std::size_t count = 0U; }; +struct PairTiming { + double dt_ref_s = 0.0; + double dz_motion_m = 0.0; + double dtau_motion_s = 0.0; +}; + struct GridDefinition { std::vector x_grid{}; std::vector z_grid{}; @@ -86,10 +99,19 @@ struct GridDefinition { std::vector> rx_distance_grids{}; }; +enum class PeakDomain { + Apparent, + Corrected, +}; + [[nodiscard]] auto make_pair_key(std::uint32_t tx_local_index, std::uint32_t rx_local_index) -> PairKey { return (static_cast(tx_local_index) << 32U) | static_cast(rx_local_index); } +[[nodiscard]] auto combo_to_string(const ipc::ComboKey& combo) -> std::string { + return "input=" + std::to_string(combo.input_pos) + " output=" + std::to_string(combo.output_pos); +} + [[nodiscard]] auto next_power_of_two(std::size_t value) -> std::size_t { if (value <= 1U) { return 1U; @@ -393,6 +415,32 @@ void fft_inplace(std::vector>& values, bool inverse) { return selection; } +void validate_collection_trace_order( + const config::RunConfig& run_config, + const ipc::PreprocessedCollection& collection +) { + if (collection.traces.size() != run_config.run_combos.size()) { + throw std::runtime_error( + "GPR requires collection trace order to match run.combos exactly: trace_count=" + + std::to_string(collection.traces.size()) + + ", run_combo_count=" + std::to_string(run_config.run_combos.size()) + ); + } + + for (std::size_t index = 0U; index < collection.traces.size(); ++index) { + const auto& actual = collection.traces[index].combo; + const auto& expected = run_config.run_combos[index]; + if (actual.input_pos == expected.input_pos && actual.output_pos == expected.output_pos) { + continue; + } + + throw std::runtime_error( + "GPR requires preprocessed trace order to match run.combos: index=" + std::to_string(index) + + ", expected=(" + combo_to_string(expected) + "), actual=(" + combo_to_string(actual) + ")" + ); + } +} + [[nodiscard]] auto build_background_mean( std::span previous_collections, const GeometrySelection& selection, @@ -451,13 +499,17 @@ void fft_inplace(std::vector>& values, bool inverse) { } [[nodiscard]] auto collect_selected_traces( + const config::RunConfig& run_config, const ipc::PreprocessedCollection& collection, const GeometrySelection& selection, const std::unordered_map>>& background_mean -) -> std::unordered_map { - std::unordered_map traces{}; +) -> std::vector { + std::vector traces{}; + traces.reserve(collection.traces.size()); + std::unordered_set seen_keys{}; - for (const auto& trace : collection.traces) { + for (std::size_t trace_index = 0U; trace_index < collection.traces.size(); ++trace_index) { + const auto& trace = collection.traces[trace_index]; const auto output_it = selection.output_local_by_pos.find(trace.combo.output_pos); const auto input_it = selection.input_local_by_pos.find(trace.combo.input_pos); if (output_it == selection.output_local_by_pos.end() || input_it == selection.input_local_by_pos.end()) { @@ -465,13 +517,23 @@ void fft_inplace(std::vector>& values, bool inverse) { } SelectedTrace selected{}; + selected.combo = trace.combo; + selected.tx_local_index = output_it->second; + selected.rx_local_index = input_it->second; + selected.run_order = trace_index; selected.frequency_hz.reserve(trace.frequency_hz.size()); for (const auto value : trace.frequency_hz) { selected.frequency_hz.push_back(static_cast(value)); } selected.s21.reserve(trace.s21.size()); - const auto key = make_pair_key(output_it->second, input_it->second); + const auto key = make_pair_key(selected.tx_local_index, selected.rx_local_index); + if (!seen_keys.insert(key).second) { + throw std::runtime_error( + "Motion-aware GPR requires unique selected combos in run.combos; duplicate combo detected: " + + combo_to_string(run_config.run_combos[trace_index]) + ); + } const auto background_it = background_mean.find(key); for (std::size_t sample_index = 0U; sample_index < trace.s21.size(); ++sample_index) { std::complex sample(trace.s21[sample_index].re, trace.s21[sample_index].im); @@ -481,7 +543,7 @@ void fft_inplace(std::vector>& values, bool inverse) { selected.s21.push_back(sample); } - traces[key] = std::move(selected); + traces.push_back(std::move(selected)); } return traces; @@ -618,6 +680,91 @@ void fft_inplace(std::vector>& values, bool inverse) { return false; } +[[nodiscard]] auto peak_depth_for_domain(const PeakRecord& peak, PeakDomain domain) -> double { + return domain == PeakDomain::Corrected ? peak.z_corr : peak.z_app; +} + +[[nodiscard]] auto peak_tau_for_domain(const PeakRecord& peak, PeakDomain domain) -> double { + return domain == PeakDomain::Corrected ? peak.tau_corr : peak.tau; +} + +[[nodiscard]] auto build_motion_timing_by_pair( + const std::vector& traces, + std::size_t total_combo_count, + std::uint64_t capture_start_ns, + std::uint64_t capture_end_ns, + const ProcessingLiveConfig& live_config, + double velocity_mps +) -> std::unordered_map { + std::unordered_map timing_by_pair{}; + timing_by_pair.reserve(traces.size()); + + const double speed_m_s = static_cast(live_config.gpr_speed_m_s); + if (!(std::abs(speed_m_s) > 1e-12)) { + for (const auto& trace : traces) { + timing_by_pair.emplace( + make_pair_key(trace.tx_local_index, trace.rx_local_index), + PairTiming{} + ); + } + return timing_by_pair; + } + + if (total_combo_count == 0U) { + throw std::runtime_error("Motion-aware GPR requires at least one run combo"); + } + if (capture_end_ns <= capture_start_ns) { + throw std::runtime_error( + "Motion-aware GPR requires valid capture_start_ns/capture_end_ns metadata when gpr_speed_m_s is non-zero" + ); + } + + const double capture_span_s = static_cast(capture_end_ns - capture_start_ns) * 1e-9; + const double slot_duration_s = capture_span_s / static_cast(total_combo_count); + if (!(slot_duration_s > 0.0)) { + throw std::runtime_error( + "Motion-aware GPR requires positive collection capture span when gpr_speed_m_s is non-zero" + ); + } + + const double t_ref_s = 0.5 * capture_span_s; + const double cos_theta = std::cos((static_cast(live_config.gpr_look_angle_deg) * kPi) / 180.0); + for (const auto& trace : traces) { + const double t_center_s = (static_cast(trace.run_order) + 0.5) * slot_duration_s; + const double dt_ref_s = t_center_s - t_ref_s; + const double dz_motion_m = speed_m_s * dt_ref_s * cos_theta; + const double dtau_motion_s = (2.0 * dz_motion_m) / velocity_mps; + timing_by_pair.emplace( + make_pair_key(trace.tx_local_index, trace.rx_local_index), + PairTiming{ + .dt_ref_s = dt_ref_s, + .dz_motion_m = dz_motion_m, + .dtau_motion_s = dtau_motion_s, + } + ); + } + + return timing_by_pair; +} + +void apply_motion_correction( + std::unordered_map>& peaks_by_pair, + const std::unordered_map& timing_by_pair, + double velocity_mps +) { + for (auto& [key, peaks] : peaks_by_pair) { + const auto timing_it = timing_by_pair.find(key); + if (timing_it == timing_by_pair.end()) { + throw std::runtime_error("Missing motion timing for selected GPR combo"); + } + + for (auto& peak : peaks) { + peak.tau_corr = peak.tau + timing_it->second.dtau_motion_s; + peak.z_corr = 0.5 * velocity_mps * peak.tau_corr; + } + } +} + [[nodiscard]] auto build_accumulator( const GridDefinition& grid, const std::unordered_map>& peaks_by_pair, @@ -625,7 +772,8 @@ void fft_inplace(std::vector>& values, bool inverse) { double velocity_mps, double shell_sigma_m, const std::vector& x_tx, - const std::vector& x_rx + const std::vector& x_rx, + PeakDomain domain ) -> std::vector { const std::size_t width = grid.x_grid.size(); const std::size_t height = grid.z_grid.size(); @@ -644,11 +792,11 @@ void fft_inplace(std::vector>& values, bool inverse) { const auto& tx_grid = grid.tx_distance_grids[tx_index]; const auto& rx_grid = grid.rx_distance_grids[rx_index]; for (const auto& peak : peak_it->second) { - if (is_excluded(peak.z_app, exclude_ranges)) { + if (is_excluded(peak_depth_for_domain(peak, domain), exclude_ranges)) { continue; } - const double range_total = velocity_mps * peak.tau; + const double range_total = velocity_mps * peak_tau_for_domain(peak, domain); for (std::size_t cell_index = 0U; cell_index < accumulator.size(); ++cell_index) { const double residual = tx_grid[cell_index] + rx_grid[cell_index] - range_total; const double shell = std::exp(-0.5 * std::pow(residual / shell_sigma_m, 2.0)); @@ -669,7 +817,8 @@ void fft_inplace(std::vector>& values, bool inverse) { const std::vector& x_tx, const std::vector& x_rx, double velocity_mps, - double shell_sigma_m + double shell_sigma_m, + PeakDomain domain ) -> double { std::size_t count = 0U; for (std::size_t tx_index = 0U; tx_index < x_tx.size(); ++tx_index) { @@ -680,13 +829,13 @@ void fft_inplace(std::vector>& values, bool inverse) { } for (const auto& peak : peak_it->second) { - if (is_excluded(peak.z_app, exclude_ranges)) { + if (is_excluded(peak_depth_for_domain(peak, domain), exclude_ranges)) { continue; } const double rt = std::sqrt(std::pow(x_est - x_tx[tx_index], 2.0) + std::pow(z_est, 2.0)); const double rr = std::sqrt(std::pow(x_est - x_rx[rx_index], 2.0) + std::pow(z_est, 2.0)); - if (std::abs((rt + rr) - (velocity_mps * peak.tau)) < shell_sigma_m * 6.0) { + if (std::abs((rt + rr) - (velocity_mps * peak_tau_for_domain(peak, domain))) < shell_sigma_m * 6.0) { count += 1U; break; } @@ -800,10 +949,11 @@ void fft_inplace(std::vector>& values, bool inverse) { const std::vector& x_tx, const std::vector& x_rx, double velocity_mps, - double shell_sigma_m + double shell_sigma_m, + PeakDomain domain ) -> std::pair, std::vector> { std::vector found{}; - const auto accumulator = build_accumulator(grid, peaks_by_pair, {}, velocity_mps, shell_sigma_m, x_tx, x_rx); + const auto accumulator = build_accumulator(grid, peaks_by_pair, {}, velocity_mps, shell_sigma_m, x_tx, x_rx, domain); const double initial_max = accumulator_max(accumulator); if (!(initial_max > 0.0) || grid.x_grid.size() < 2U || grid.z_grid.size() < 2U) { return {found, gaussian_filter_2d(accumulator, grid.x_grid.size(), grid.z_grid.size(), kGaussianSigma)}; @@ -816,7 +966,16 @@ void fft_inplace(std::vector>& values, bool inverse) { std::vector> excluded_ranges{}; for (std::size_t step = 0U; step < kMaxObjects; ++step) { - const auto current = build_accumulator(grid, peaks_by_pair, excluded_ranges, velocity_mps, shell_sigma_m, x_tx, x_rx); + const auto current = build_accumulator( + grid, + peaks_by_pair, + excluded_ranges, + velocity_mps, + shell_sigma_m, + x_tx, + x_rx, + domain + ); const auto smoothed = gaussian_filter_2d(current, grid.x_grid.size(), grid.z_grid.size(), kGaussianSigma); const double smoothed_max = accumulator_max(smoothed); if (!(smoothed_max > (kCleanThresholdFrac * initial_max))) { @@ -842,7 +1001,17 @@ void fft_inplace(std::vector>& values, bool inverse) { PointRecord point{}; point.x_m = x_est; point.z_m = z_est; - point.score = count_agreeing_ellipses(x_est, z_est, peaks_by_pair, excluded_ranges, x_tx, x_rx, velocity_mps, shell_sigma_m); + point.score = count_agreeing_ellipses( + x_est, + z_est, + peaks_by_pair, + excluded_ranges, + x_tx, + x_rx, + velocity_mps, + shell_sigma_m, + domain + ); found.push_back(point); std::vector matched_depths{}; @@ -853,13 +1022,13 @@ void fft_inplace(std::vector>& values, bool inverse) { continue; } for (const auto& peak : peak_it->second) { - if (is_excluded(peak.z_app, excluded_ranges)) { + if (is_excluded(peak_depth_for_domain(peak, domain), excluded_ranges)) { continue; } const double rt = std::sqrt(std::pow(x_est - x_tx[tx_index], 2.0) + std::pow(z_est, 2.0)); const double rr = std::sqrt(std::pow(x_est - x_rx[rx_index], 2.0) + std::pow(z_est, 2.0)); - if (std::abs((rt + rr) - (velocity_mps * peak.tau)) < shell_sigma_m * 3.0) { - matched_depths.push_back(peak.z_app); + if (std::abs((rt + rr) - (velocity_mps * peak_tau_for_domain(peak, domain))) < shell_sigma_m * 3.0) { + matched_depths.push_back(peak_depth_for_domain(peak, domain)); } } } @@ -883,7 +1052,16 @@ void fft_inplace(std::vector>& values, bool inverse) { double shell_sigma_m ) -> std::pair, std::vector> { std::vector regions{}; - const auto accumulator = build_accumulator(grid, peaks_by_pair, {}, velocity_mps, shell_sigma_m, x_tx, x_rx); + const auto accumulator = build_accumulator( + grid, + peaks_by_pair, + {}, + velocity_mps, + shell_sigma_m, + x_tx, + x_rx, + PeakDomain::Apparent + ); const auto smoothed = gaussian_filter_2d(accumulator, grid.x_grid.size(), grid.z_grid.size(), kGaussianSigma); const double smoothed_max = accumulator_max(smoothed); if (!(smoothed_max > 0.0) || grid.x_grid.size() < 2U || grid.z_grid.size() < 2U) { @@ -968,7 +1146,17 @@ void fft_inplace(std::vector>& values, bool inverse) { region.x_m = x_weight_sum / weight_sum; region.z_m = z_weight_sum / weight_sum; - region.score = count_agreeing_ellipses(region.x_m, region.z_m, peaks_by_pair, {}, x_tx, x_rx, velocity_mps, shell_sigma_m); + region.score = count_agreeing_ellipses( + region.x_m, + region.z_m, + peaks_by_pair, + {}, + x_tx, + x_rx, + velocity_mps, + shell_sigma_m, + PeakDomain::Apparent + ); region.pixel_count = static_cast(component.size()); regions.push_back(std::move(region)); } @@ -998,9 +1186,10 @@ auto GprProcessor::process_collection( return results; } + validate_collection_trace_order(run_config, collection); const auto background_mean = build_background_mean(previous_collections, selection, live_config); - const auto traces_by_pair = collect_selected_traces(collection, selection, background_mean); - if (traces_by_pair.empty()) { + const auto selected_traces = collect_selected_traces(run_config, collection, selection, background_mean); + if (selected_traces.empty()) { return results; } @@ -1011,7 +1200,8 @@ auto GprProcessor::process_collection( std::unordered_map ascans_by_pair{}; double bandwidth_hz = 0.0; - for (const auto& [key, trace] : traces_by_pair) { + for (const auto& trace : selected_traces) { + const auto key = make_pair_key(trace.tx_local_index, trace.rx_local_index); auto ascan = compute_ascan(trace, start_hz, stop_hz, velocity_mps); if (ascan.amplitude.empty() || !(ascan.bandwidth_hz > 0.0)) { continue; @@ -1044,13 +1234,15 @@ auto GprProcessor::process_collection( continue; } - const double noise = median_copy(ascan.amplitude); const auto min_index = lower_bound_index(ascan.depth_m, static_cast(live_config.gpr_min_depth_m)); const auto max_index = lower_bound_index(ascan.depth_m, static_cast(live_config.gpr_max_depth_m)); if (max_index <= min_index + 2U) { continue; } + const double noise = + median_copy(std::vector(ascan.amplitude.begin() + min_index, ascan.amplitude.begin() + max_index)); + const double z_step = std::max(ascan.depth_m[1] - ascan.depth_m[0], 1e-6); const std::size_t min_distance = static_cast(std::max( 4.0, @@ -1064,7 +1256,7 @@ auto GprProcessor::process_collection( const double snr_raw = ascan.amplitude[peak_index] / std::max(noise, 1e-12); const double attenuation = attenuation_at_depth(tx_index, rx_index, z_app, selection.x_tx, selection.x_rx); const double attenuation_norm = - attenuation / attenuation_at_depth(tx_index, rx_index, 2.0, selection.x_tx, selection.x_rx); + attenuation / attenuation_at_depth(tx_index, rx_index, 3.0, selection.x_tx, selection.x_rx); const double snr_comp = std::min( snr_raw / (std::pow(attenuation_norm, static_cast(live_config.gpr_comp_power)) + 1e-12), kSnrCompMax @@ -1072,6 +1264,8 @@ auto GprProcessor::process_collection( peaks.push_back(PeakRecord{ .z_app = z_app, .tau = ascan.time_s[peak_index], + .tau_corr = ascan.time_s[peak_index], + .z_corr = z_app, .snr_raw = snr_raw, .snr_comp = snr_comp, }); @@ -1121,13 +1315,24 @@ auto GprProcessor::process_collection( return results; } + const auto motion_timing_by_pair = build_motion_timing_by_pair( + selected_traces, + run_config.run_combos.size(), + collection.capture_start_ns, + collection.capture_end_ns, + live_config, + velocity_mps + ); + apply_motion_correction(peaks_by_pair, motion_timing_by_pair, velocity_mps); + const auto [points, smoothed_accumulator] = clean_find_points( grid, peaks_by_pair, selection.x_tx, selection.x_rx, velocity_mps, - shell_sigma_m + shell_sigma_m, + PeakDomain::Corrected ); results.collection_payloads.push_back(build_image_payload("gpr_accumulator", grid.x_grid, grid.z_grid, smoothed_accumulator)); diff --git a/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp b/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp index a58ece3..d30fddf 100644 --- a/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp +++ b/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp @@ -142,6 +142,7 @@ auto SweepOrchestrator::acquire_one_collection( collection.collection_id = collection_id; collection.monotonic_ns = ipc::current_monotonic_ns(); collection.traces.reserve(config_.run_combos.size()); + collection.capture_start_ns = ipc::current_monotonic_ns(); bool interrupted = false; for (const auto& combo : config_.run_combos) { @@ -170,8 +171,13 @@ auto SweepOrchestrator::acquire_one_collection( if (interrupted) { // Do not emit partial collections when stop was requested mid-cycle. collection.traces.clear(); + collection.capture_start_ns = 0; + collection.capture_end_ns = 0; + return collection; } + collection.capture_end_ns = collection.traces.empty() ? 0U : ipc::current_monotonic_ns(); + return collection; } diff --git a/python_app/gui/controllers/app_window_config_mixin.py b/python_app/gui/controllers/app_window_config_mixin.py index 012cd5c..7df7d80 100644 --- a/python_app/gui/controllers/app_window_config_mixin.py +++ b/python_app/gui/controllers/app_window_config_mixin.py @@ -165,6 +165,8 @@ class AppWindowConfigMixin: comp_power=0.2, start_freq_mhz=3000.0, stop_freq_mhz=6000.0, + speed_m_s=0.0, + look_angle_deg=0.0, background_subtract_enabled=True, background_mean_count=10, ), @@ -217,6 +219,8 @@ class AppWindowConfigMixin: comp_power=float(self._gpr_comp_power.value()), start_freq_mhz=float(self._gpr_start_freq_mhz.value()), stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()), + speed_m_s=float(self._gpr_speed_m_s.value()), + look_angle_deg=float(self._gpr_look_angle_deg.value()), background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()), background_mean_count=int(self._gpr_background_mean_count.value()), ), @@ -315,7 +319,7 @@ class AppWindowConfigMixin: details=self._capture_state_details(), ) return - if self._supervisor.is_running() or self._supervisor.is_processor_running(): + if self._supervisor.is_running(): self._show_error( "Stop all pipeline processes before loading a config profile", details=self._process_state_details(), @@ -332,23 +336,26 @@ class AppWindowConfigMixin: return try: - self._load_config_profile(Path(selected_path)) + normalized_path = self._normalize_profile_path(Path(selected_path)) + profile = GuiProfileModel.load_from_path(normalized_path) + processor_running = self._supervisor.is_processor_running() + self._apply_loaded_profile(profile, normalized_path) + profile_kind = "legacy run config" if profile.gui is None else "full GUI profile" + message = ( + f"Config profile loaded: path={normalized_path}, " + f"kind={profile_kind}, " + f"combos={len(self._defaults_config.combos)}, " + f"processing_mode={self._processing_mode.currentText()}" + ) + if processor_running: + message += ( + "; data_processor is still running, so live processing settings were applied immediately " + "and stable settings are now staged in the UI for the next Start" + ) + self._log(message) except Exception as exc: # noqa: BLE001 self._show_exception("Failed to load config profile", exc) - def _load_config_profile(self, profile_path: Path) -> None: - """Load config profile from `profile_path` and atomically apply it to the UI.""" - normalized_path = self._normalize_profile_path(profile_path) - profile = GuiProfileModel.load_from_path(normalized_path) - self._apply_loaded_profile(profile, normalized_path) - profile_kind = "legacy run config" if profile.gui is None else "full GUI profile" - self._log( - f"Config profile loaded: path={normalized_path}, " - f"kind={profile_kind}, " - f"combos={len(self._defaults_config.combos)}, " - f"processing_mode={self._processing_mode.currentText()}" - ) - def _apply_loaded_profile(self, profile: GuiProfileModel, profile_path: Path) -> None: """Apply already parsed profile to GUI state without restarting the pipeline.""" config = profile.run_config.clone() @@ -395,6 +402,8 @@ class AppWindowConfigMixin: self._gpr_comp_power, self._gpr_start_freq_mhz, self._gpr_stop_freq_mhz, + self._gpr_speed_m_s, + self._gpr_look_angle_deg, self._gpr_background_subtract_enabled, self._gpr_background_mean_count, self._save_count, @@ -455,6 +464,8 @@ class AppWindowConfigMixin: self._gpr_comp_power.setValue(float(gui_state.processing.gpr.comp_power)) self._gpr_start_freq_mhz.setValue(float(gui_state.processing.gpr.start_freq_mhz)) self._gpr_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz)) + self._gpr_speed_m_s.setValue(float(gui_state.processing.gpr.speed_m_s)) + self._gpr_look_angle_deg.setValue(float(gui_state.processing.gpr.look_angle_deg)) self._gpr_background_subtract_enabled.setChecked( bool(gui_state.processing.gpr.background_subtract_enabled) ) @@ -567,6 +578,8 @@ class AppWindowConfigMixin: gpr_comp_power=float(self._gpr_comp_power.value()), gpr_start_freq_mhz=float(self._gpr_start_freq_mhz.value()), gpr_stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()), + gpr_speed_m_s=float(self._gpr_speed_m_s.value()), + gpr_look_angle_deg=float(self._gpr_look_angle_deg.value()), gpr_background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()), gpr_background_mean_count=int(self._gpr_background_mean_count.value()), history_command_seq=int(self._history_command_seq), @@ -640,6 +653,8 @@ class AppWindowConfigMixin: f"outputs={self._gpr_output_positions_input.text().strip() or ''}, " f"depth={self._gpr_min_depth_m.value():g}..{self._gpr_max_depth_m.value():g} m, " f"freq={self._gpr_start_freq_mhz.value():g}..{self._gpr_stop_freq_mhz.value():g} MHz, " + f"speed={self._gpr_speed_m_s.value():g} m/s, " + f"look_angle={self._gpr_look_angle_deg.value():g} deg, " f"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, " f"mean_count={self._gpr_background_mean_count.value()})" ) diff --git a/python_app/gui/controllers/app_window_pipeline_mixin.py b/python_app/gui/controllers/app_window_pipeline_mixin.py index e0d7a97..d393c1b 100644 --- a/python_app/gui/controllers/app_window_pipeline_mixin.py +++ b/python_app/gui/controllers/app_window_pipeline_mixin.py @@ -39,10 +39,10 @@ class AppWindowPipelineMixin: try: processor_was_running = self._supervisor.is_processor_running() + config = self._build_config() if not processor_was_running: self._reset_runtime_history() - config = self._build_config() self._validate_processing_mode_constraints(config) run_signature = self._build_run_history_signature(config) radar_key = self._radar_key(config) diff --git a/python_app/gui/controllers/sections/processing_section.py b/python_app/gui/controllers/sections/processing_section.py index 616bd9c..23699c4 100644 --- a/python_app/gui/controllers/sections/processing_section.py +++ b/python_app/gui/controllers/sections/processing_section.py @@ -191,6 +191,18 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_stop_freq_mhz.setSingleStep(10.0) owner._gpr_stop_freq_mhz.setValue(float(gpr_live_defaults.stop_freq_mhz)) + owner._gpr_speed_m_s = QDoubleSpinBox() + owner._gpr_speed_m_s.setDecimals(3) + owner._gpr_speed_m_s.setRange(-100.0, 100.0) + owner._gpr_speed_m_s.setSingleStep(0.01) + owner._gpr_speed_m_s.setValue(float(gpr_live_defaults.speed_m_s)) + + owner._gpr_look_angle_deg = QDoubleSpinBox() + owner._gpr_look_angle_deg.setDecimals(2) + owner._gpr_look_angle_deg.setRange(-90.0, 90.0) + owner._gpr_look_angle_deg.setSingleStep(0.1) + owner._gpr_look_angle_deg.setValue(float(gpr_live_defaults.look_angle_deg)) + owner._gpr_background_subtract_enabled = QCheckBox("Subtract mean of previous collections") owner._gpr_background_subtract_enabled.setChecked(bool(gpr_live_defaults.background_subtract_enabled)) @@ -209,6 +221,8 @@ def build_processing_group(owner) -> QGroupBox: gpr_form.addRow("Comp power", owner._gpr_comp_power) gpr_form.addRow("Start MHz", owner._gpr_start_freq_mhz) gpr_form.addRow("Stop MHz", owner._gpr_stop_freq_mhz) + gpr_form.addRow("Speed m/s", owner._gpr_speed_m_s) + gpr_form.addRow("Look angle deg", owner._gpr_look_angle_deg) gpr_form.addRow(owner._gpr_background_subtract_enabled) gpr_form.addRow("Mean count", owner._gpr_background_mean_count) owner._processing_mode_pages.addWidget(gpr_page) @@ -236,6 +250,8 @@ def build_processing_group(owner) -> QGroupBox: owner._gpr_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_speed_m_s.valueChanged.connect(owner._on_processing_live_settings_changed) + owner._gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed) owner._gpr_background_subtract_enabled.toggled.connect(owner._on_processing_live_settings_changed) owner._gpr_background_mean_count.valueChanged.connect(owner._on_processing_live_settings_changed) diff --git a/python_app/models/dataset_model.py b/python_app/models/dataset_model.py index 78ab8ae..69f13dd 100644 --- a/python_app/models/dataset_model.py +++ b/python_app/models/dataset_model.py @@ -47,6 +47,8 @@ class SweepCollection: collection_id: int monotonic_ns: int traces: list[TraceData] = field(default_factory=list) + capture_start_ns: int = 0 + capture_end_ns: int = 0 @dataclass(slots=True) diff --git a/python_app/models/gui_profile_codec.py b/python_app/models/gui_profile_codec.py index 3a72af8..6cc8b76 100644 --- a/python_app/models/gui_profile_codec.py +++ b/python_app/models/gui_profile_codec.py @@ -196,6 +196,18 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel: gui.processing.gpr.stop_freq_mhz, "gui.processing.gpr", ), + speed_m_s=_optional_float( + gpr_object, + "speed_m_s", + gui.processing.gpr.speed_m_s, + "gui.processing.gpr", + ), + look_angle_deg=_optional_float( + gpr_object, + "look_angle_deg", + gui.processing.gpr.look_angle_deg, + "gui.processing.gpr", + ), background_subtract_enabled=_optional_bool( gpr_object, "background_subtract_enabled", @@ -288,6 +300,8 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: "comp_power": gui.processing.gpr.comp_power, "start_freq_mhz": gui.processing.gpr.start_freq_mhz, "stop_freq_mhz": gui.processing.gpr.stop_freq_mhz, + "speed_m_s": gui.processing.gpr.speed_m_s, + "look_angle_deg": gui.processing.gpr.look_angle_deg, "background_subtract_enabled": gui.processing.gpr.background_subtract_enabled, "background_mean_count": gui.processing.gpr.background_mean_count, }, @@ -302,4 +316,3 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]: }, } return payload - diff --git a/python_app/models/gui_profile_schema.py b/python_app/models/gui_profile_schema.py index 630fcf5..2a164c6 100644 --- a/python_app/models/gui_profile_schema.py +++ b/python_app/models/gui_profile_schema.py @@ -55,6 +55,8 @@ class GuiGprStateModel: comp_power: float = 0.2 start_freq_mhz: float = 3000.0 stop_freq_mhz: float = 6000.0 + speed_m_s: float = 0.0 + look_angle_deg: float = 0.0 background_subtract_enabled: bool = True background_mean_count: int = 10 diff --git a/python_app/orchestration/live_processing_config.py b/python_app/orchestration/live_processing_config.py index 805034f..174e4dc 100644 --- a/python_app/orchestration/live_processing_config.py +++ b/python_app/orchestration/live_processing_config.py @@ -30,6 +30,8 @@ class ProcessingLiveConfig: gpr_comp_power: float = 0.2 gpr_start_freq_mhz: float = 3000.0 gpr_stop_freq_mhz: float = 6000.0 + gpr_speed_m_s: float = 0.0 + gpr_look_angle_deg: float = 0.0 gpr_background_subtract_enabled: bool = True gpr_background_mean_count: int = 10 history_command_seq: int = 0 @@ -68,6 +70,8 @@ class ProcessingLiveConfig: "gpr_comp_power": float(self.gpr_comp_power), "gpr_start_freq_mhz": float(self.gpr_start_freq_mhz), "gpr_stop_freq_mhz": float(self.gpr_stop_freq_mhz), + "gpr_speed_m_s": float(self.gpr_speed_m_s), + "gpr_look_angle_deg": float(self.gpr_look_angle_deg), "gpr_background_subtract_enabled": bool(self.gpr_background_subtract_enabled), "gpr_background_mean_count": int(self.gpr_background_mean_count), "history_command_seq": int(self.history_command_seq), diff --git a/python_app/orchestration/shm/binary_cursor.py b/python_app/orchestration/shm/binary_cursor.py index 8774d63..ea824ce 100644 --- a/python_app/orchestration/shm/binary_cursor.py +++ b/python_app/orchestration/shm/binary_cursor.py @@ -48,3 +48,7 @@ class ByteCursor: data = self.payload[self.offset : self.offset + size] self.offset += size return data + + def remaining_bytes(self) -> int: + """Return unread byte count.""" + return len(self.payload) - self.offset diff --git a/python_app/orchestration/shm/decoder.py b/python_app/orchestration/shm/decoder.py index 147fa84..0f721ab 100644 --- a/python_app/orchestration/shm/decoder.py +++ b/python_app/orchestration/shm/decoder.py @@ -54,7 +54,21 @@ def decode_trace_collection(payload: bytes, expected_magic: int) -> SweepCollect ) ) - return SweepCollection(collection_id=collection_id, monotonic_ns=monotonic_ns, traces=traces) + capture_start_ns = 0 + capture_end_ns = 0 + if cursor.remaining_bytes() == 16: + capture_start_ns = cursor.read_u64() + capture_end_ns = cursor.read_u64() + elif cursor.remaining_bytes() != 0: + raise ValueError("Unexpected trailing bytes in trace collection") + + return SweepCollection( + collection_id=collection_id, + monotonic_ns=monotonic_ns, + traces=traces, + capture_start_ns=capture_start_ns, + capture_end_ns=capture_end_ns, + ) def decode_result_collection(payload: bytes) -> ResultCollection: diff --git a/python_app/storage/npz/serialize.py b/python_app/storage/npz/serialize.py index 855f5a6..b9f4bd0 100644 --- a/python_app/storage/npz/serialize.py +++ b/python_app/storage/npz/serialize.py @@ -41,6 +41,13 @@ def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes _write_interleaved_complex(buffer, s11) _write_interleaved_complex(buffer, s21) + buffer.extend( + struct.pack( + " { "collection_id": int(collection.collection_id), "monotonic_ns": int(collection.monotonic_ns), + "capture_start_ns": int(collection.capture_start_ns), + "capture_end_ns": int(collection.capture_end_ns), "trace_count": len(collection.traces), "traces": traces_meta, }, diff --git a/python_app/storage/npz/store.py b/python_app/storage/npz/store.py index c323b6d..c0a62a5 100644 --- a/python_app/storage/npz/store.py +++ b/python_app/storage/npz/store.py @@ -64,6 +64,8 @@ class NpzStore(StoreApi): meta = { "collection_id": int(collection.collection_id), "monotonic_ns": int(collection.monotonic_ns), + "capture_start_ns": int(collection.capture_start_ns), + "capture_end_ns": int(collection.capture_end_ns), "combos": combo_records, } meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8") @@ -98,6 +100,8 @@ class NpzStore(StoreApi): collection_id=int(meta["collection_id"]), monotonic_ns=int(meta["monotonic_ns"]), traces=traces, + capture_start_ns=int(meta.get("capture_start_ns", 0)), + capture_end_ns=int(meta.get("capture_end_ns", 0)), ) def list_sets(self, kind: str, radar_key: str) -> list[str]: