added gpr speed tracking
This commit is contained in:
@@ -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()
|
||||||
@@ -43,6 +43,8 @@ struct SweepTraceBlock {
|
|||||||
struct RawSweepCollection {
|
struct RawSweepCollection {
|
||||||
std::uint64_t collection_id = 0;
|
std::uint64_t collection_id = 0;
|
||||||
std::uint64_t monotonic_ns = 0;
|
std::uint64_t monotonic_ns = 0;
|
||||||
|
std::uint64_t capture_start_ns = 0;
|
||||||
|
std::uint64_t capture_end_ns = 0;
|
||||||
std::vector<SweepTraceBlock> traces{};
|
std::vector<SweepTraceBlock> traces{};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ class BinaryReader {
|
|||||||
return offset_ == bytes_.size();
|
return offset_ == bytes_.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto remaining_bytes() const -> std::size_t {
|
||||||
|
return bytes_.size() - offset_;
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void ensure_available(std::size_t size) const {
|
void ensure_available(std::size_t size) const {
|
||||||
if (offset_ + size > bytes_.size()) {
|
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) {
|
for (const auto& trace : collection.traces) {
|
||||||
write_trace_block(writer, trace);
|
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 {
|
[[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));
|
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<std::uint64_t>();
|
||||||
|
collection.capture_end_ns = reader.read<std::uint64_t>();
|
||||||
|
|
||||||
return collection;
|
return collection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ auto DataPreprocessor::preprocess_collection(const ipc::RawSweepCollection& raw_
|
|||||||
ipc::PreprocessedCollection preprocessed{};
|
ipc::PreprocessedCollection preprocessed{};
|
||||||
preprocessed.collection_id = raw_collection.collection_id;
|
preprocessed.collection_id = raw_collection.collection_id;
|
||||||
preprocessed.monotonic_ns = ipc::current_monotonic_ns();
|
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());
|
preprocessed.traces.reserve(raw_collection.traces.size());
|
||||||
|
|
||||||
for (const auto& raw_trace : raw_collection.traces) {
|
for (const auto& raw_trace : raw_collection.traces) {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ struct ProcessingLiveConfig {
|
|||||||
float gpr_comp_power = 0.2F;
|
float gpr_comp_power = 0.2F;
|
||||||
float gpr_start_freq_mhz = 3000.0F;
|
float gpr_start_freq_mhz = 3000.0F;
|
||||||
float gpr_stop_freq_mhz = 6000.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;
|
bool gpr_background_subtract_enabled = true;
|
||||||
std::uint32_t gpr_background_mean_count = 10U;
|
std::uint32_t gpr_background_mean_count = 10U;
|
||||||
std::uint64_t history_command_seq = 0;
|
std::uint64_t history_command_seq = 0;
|
||||||
|
|||||||
@@ -199,6 +199,18 @@ using Json = nlohmann::json;
|
|||||||
}
|
}
|
||||||
config.gpr_stop_freq_mhz = static_cast<float>(found->get<double>());
|
config.gpr_stop_freq_mhz = static_cast<float>(found->get<double>());
|
||||||
}
|
}
|
||||||
|
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<float>(found->get<double>());
|
||||||
|
}
|
||||||
|
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<float>(found->get<double>());
|
||||||
|
}
|
||||||
if (const auto found = root.find("gpr_background_subtract_enabled"); found != root.end()) {
|
if (const auto found = root.find("gpr_background_subtract_enabled"); found != root.end()) {
|
||||||
if (!found->is_boolean()) {
|
if (!found->is_boolean()) {
|
||||||
throw std::runtime_error("processing.gpr_background_subtract_enabled must be bool");
|
throw std::runtime_error("processing.gpr_background_subtract_enabled must be bool");
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <numeric>
|
#include <numeric>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include <unordered_set>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -18,9 +19,9 @@ namespace {
|
|||||||
constexpr double kPi = 3.14159265358979323846;
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
constexpr double kSpeedOfLightMetersPerSec = 299'792'458.0;
|
constexpr double kSpeedOfLightMetersPerSec = 299'792'458.0;
|
||||||
constexpr double kAccumulatorXMarginM = 2.0;
|
constexpr double kAccumulatorXMarginM = 2.0;
|
||||||
constexpr double kAccumulatorZMinM = 0.10;
|
constexpr double kAccumulatorZMinM = 0.20;
|
||||||
constexpr double kSnrThresh = 3.0;
|
constexpr double kSnrThresh = 4.5;
|
||||||
constexpr double kSnrCompMax = 20.0;
|
constexpr double kSnrCompMax = 25.0;
|
||||||
constexpr std::size_t kGridWidth = 300U;
|
constexpr std::size_t kGridWidth = 300U;
|
||||||
constexpr std::size_t kGridHeight = 300U;
|
constexpr std::size_t kGridHeight = 300U;
|
||||||
constexpr double kGaussianSigma = 3.0;
|
constexpr double kGaussianSigma = 3.0;
|
||||||
@@ -33,6 +34,10 @@ constexpr double kExtendedMinAreaCm2 = 2.0;
|
|||||||
using PairKey = std::uint64_t;
|
using PairKey = std::uint64_t;
|
||||||
|
|
||||||
struct SelectedTrace {
|
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<double> frequency_hz{};
|
std::vector<double> frequency_hz{};
|
||||||
std::vector<std::complex<double>> s21{};
|
std::vector<std::complex<double>> s21{};
|
||||||
};
|
};
|
||||||
@@ -47,6 +52,8 @@ struct AscanResult {
|
|||||||
struct PeakRecord {
|
struct PeakRecord {
|
||||||
double z_app = 0.0;
|
double z_app = 0.0;
|
||||||
double tau = 0.0;
|
double tau = 0.0;
|
||||||
|
double tau_corr = 0.0;
|
||||||
|
double z_corr = 0.0;
|
||||||
double snr_raw = 0.0;
|
double snr_raw = 0.0;
|
||||||
double snr_comp = 0.0;
|
double snr_comp = 0.0;
|
||||||
};
|
};
|
||||||
@@ -79,6 +86,12 @@ struct BackgroundAccumulator {
|
|||||||
std::size_t count = 0U;
|
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 {
|
struct GridDefinition {
|
||||||
std::vector<double> x_grid{};
|
std::vector<double> x_grid{};
|
||||||
std::vector<double> z_grid{};
|
std::vector<double> z_grid{};
|
||||||
@@ -86,10 +99,19 @@ struct GridDefinition {
|
|||||||
std::vector<std::vector<double>> rx_distance_grids{};
|
std::vector<std::vector<double>> 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 {
|
[[nodiscard]] auto make_pair_key(std::uint32_t tx_local_index, std::uint32_t rx_local_index) -> PairKey {
|
||||||
return (static_cast<PairKey>(tx_local_index) << 32U) | static_cast<PairKey>(rx_local_index);
|
return (static_cast<PairKey>(tx_local_index) << 32U) | static_cast<PairKey>(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 {
|
[[nodiscard]] auto next_power_of_two(std::size_t value) -> std::size_t {
|
||||||
if (value <= 1U) {
|
if (value <= 1U) {
|
||||||
return 1U;
|
return 1U;
|
||||||
@@ -393,6 +415,32 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
|
|||||||
return selection;
|
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(
|
[[nodiscard]] auto build_background_mean(
|
||||||
std::span<const ipc::PreprocessedCollection> previous_collections,
|
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||||
const GeometrySelection& selection,
|
const GeometrySelection& selection,
|
||||||
@@ -451,13 +499,17 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[[nodiscard]] auto collect_selected_traces(
|
[[nodiscard]] auto collect_selected_traces(
|
||||||
|
const config::RunConfig& run_config,
|
||||||
const ipc::PreprocessedCollection& collection,
|
const ipc::PreprocessedCollection& collection,
|
||||||
const GeometrySelection& selection,
|
const GeometrySelection& selection,
|
||||||
const std::unordered_map<PairKey, std::vector<std::complex<double>>>& background_mean
|
const std::unordered_map<PairKey, std::vector<std::complex<double>>>& background_mean
|
||||||
) -> std::unordered_map<PairKey, SelectedTrace> {
|
) -> std::vector<SelectedTrace> {
|
||||||
std::unordered_map<PairKey, SelectedTrace> traces{};
|
std::vector<SelectedTrace> traces{};
|
||||||
|
traces.reserve(collection.traces.size());
|
||||||
|
std::unordered_set<PairKey> 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 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);
|
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()) {
|
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<std::complex<double>>& values, bool inverse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SelectedTrace selected{};
|
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());
|
selected.frequency_hz.reserve(trace.frequency_hz.size());
|
||||||
for (const auto value : trace.frequency_hz) {
|
for (const auto value : trace.frequency_hz) {
|
||||||
selected.frequency_hz.push_back(static_cast<double>(value));
|
selected.frequency_hz.push_back(static_cast<double>(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
selected.s21.reserve(trace.s21.size());
|
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);
|
const auto background_it = background_mean.find(key);
|
||||||
for (std::size_t sample_index = 0U; sample_index < trace.s21.size(); ++sample_index) {
|
for (std::size_t sample_index = 0U; sample_index < trace.s21.size(); ++sample_index) {
|
||||||
std::complex<double> sample(trace.s21[sample_index].re, trace.s21[sample_index].im);
|
std::complex<double> sample(trace.s21[sample_index].re, trace.s21[sample_index].im);
|
||||||
@@ -481,7 +543,7 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
|
|||||||
selected.s21.push_back(sample);
|
selected.s21.push_back(sample);
|
||||||
}
|
}
|
||||||
|
|
||||||
traces[key] = std::move(selected);
|
traces.push_back(std::move(selected));
|
||||||
}
|
}
|
||||||
|
|
||||||
return traces;
|
return traces;
|
||||||
@@ -618,6 +680,91 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
|
|||||||
return false;
|
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<SelectedTrace>& 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<PairKey, PairTiming> {
|
||||||
|
std::unordered_map<PairKey, PairTiming> timing_by_pair{};
|
||||||
|
timing_by_pair.reserve(traces.size());
|
||||||
|
|
||||||
|
const double speed_m_s = static_cast<double>(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<double>(capture_end_ns - capture_start_ns) * 1e-9;
|
||||||
|
const double slot_duration_s = capture_span_s / static_cast<double>(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<double>(live_config.gpr_look_angle_deg) * kPi) / 180.0);
|
||||||
|
for (const auto& trace : traces) {
|
||||||
|
const double t_center_s = (static_cast<double>(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<PairKey, std::vector<PeakRecord>>& peaks_by_pair,
|
||||||
|
const std::unordered_map<PairKey, PairTiming>& 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(
|
[[nodiscard]] auto build_accumulator(
|
||||||
const GridDefinition& grid,
|
const GridDefinition& grid,
|
||||||
const std::unordered_map<PairKey, std::vector<PeakRecord>>& peaks_by_pair,
|
const std::unordered_map<PairKey, std::vector<PeakRecord>>& peaks_by_pair,
|
||||||
@@ -625,7 +772,8 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
|
|||||||
double velocity_mps,
|
double velocity_mps,
|
||||||
double shell_sigma_m,
|
double shell_sigma_m,
|
||||||
const std::vector<double>& x_tx,
|
const std::vector<double>& x_tx,
|
||||||
const std::vector<double>& x_rx
|
const std::vector<double>& x_rx,
|
||||||
|
PeakDomain domain
|
||||||
) -> std::vector<double> {
|
) -> std::vector<double> {
|
||||||
const std::size_t width = grid.x_grid.size();
|
const std::size_t width = grid.x_grid.size();
|
||||||
const std::size_t height = grid.z_grid.size();
|
const std::size_t height = grid.z_grid.size();
|
||||||
@@ -644,11 +792,11 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
|
|||||||
const auto& tx_grid = grid.tx_distance_grids[tx_index];
|
const auto& tx_grid = grid.tx_distance_grids[tx_index];
|
||||||
const auto& rx_grid = grid.rx_distance_grids[rx_index];
|
const auto& rx_grid = grid.rx_distance_grids[rx_index];
|
||||||
for (const auto& peak : peak_it->second) {
|
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;
|
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) {
|
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 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));
|
const double shell = std::exp(-0.5 * std::pow(residual / shell_sigma_m, 2.0));
|
||||||
@@ -669,7 +817,8 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
|
|||||||
const std::vector<double>& x_tx,
|
const std::vector<double>& x_tx,
|
||||||
const std::vector<double>& x_rx,
|
const std::vector<double>& x_rx,
|
||||||
double velocity_mps,
|
double velocity_mps,
|
||||||
double shell_sigma_m
|
double shell_sigma_m,
|
||||||
|
PeakDomain domain
|
||||||
) -> double {
|
) -> double {
|
||||||
std::size_t count = 0U;
|
std::size_t count = 0U;
|
||||||
for (std::size_t tx_index = 0U; tx_index < x_tx.size(); ++tx_index) {
|
for (std::size_t tx_index = 0U; tx_index < x_tx.size(); ++tx_index) {
|
||||||
@@ -680,13 +829,13 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const auto& peak : peak_it->second) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const double rt = std::sqrt(std::pow(x_est - x_tx[tx_index], 2.0) + std::pow(z_est, 2.0));
|
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));
|
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;
|
count += 1U;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -800,10 +949,11 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
|
|||||||
const std::vector<double>& x_tx,
|
const std::vector<double>& x_tx,
|
||||||
const std::vector<double>& x_rx,
|
const std::vector<double>& x_rx,
|
||||||
double velocity_mps,
|
double velocity_mps,
|
||||||
double shell_sigma_m
|
double shell_sigma_m,
|
||||||
|
PeakDomain domain
|
||||||
) -> std::pair<std::vector<PointRecord>, std::vector<double>> {
|
) -> std::pair<std::vector<PointRecord>, std::vector<double>> {
|
||||||
std::vector<PointRecord> found{};
|
std::vector<PointRecord> 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);
|
const double initial_max = accumulator_max(accumulator);
|
||||||
if (!(initial_max > 0.0) || grid.x_grid.size() < 2U || grid.z_grid.size() < 2U) {
|
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)};
|
return {found, gaussian_filter_2d(accumulator, grid.x_grid.size(), grid.z_grid.size(), kGaussianSigma)};
|
||||||
@@ -816,7 +966,16 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
|
|||||||
|
|
||||||
std::vector<std::pair<double, double>> excluded_ranges{};
|
std::vector<std::pair<double, double>> excluded_ranges{};
|
||||||
for (std::size_t step = 0U; step < kMaxObjects; ++step) {
|
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 auto smoothed = gaussian_filter_2d(current, grid.x_grid.size(), grid.z_grid.size(), kGaussianSigma);
|
||||||
const double smoothed_max = accumulator_max(smoothed);
|
const double smoothed_max = accumulator_max(smoothed);
|
||||||
if (!(smoothed_max > (kCleanThresholdFrac * initial_max))) {
|
if (!(smoothed_max > (kCleanThresholdFrac * initial_max))) {
|
||||||
@@ -842,7 +1001,17 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
|
|||||||
PointRecord point{};
|
PointRecord point{};
|
||||||
point.x_m = x_est;
|
point.x_m = x_est;
|
||||||
point.z_m = z_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);
|
found.push_back(point);
|
||||||
|
|
||||||
std::vector<double> matched_depths{};
|
std::vector<double> matched_depths{};
|
||||||
@@ -853,13 +1022,13 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const auto& peak : peak_it->second) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
const double rt = std::sqrt(std::pow(x_est - x_tx[tx_index], 2.0) + std::pow(z_est, 2.0));
|
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));
|
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) {
|
if (std::abs((rt + rr) - (velocity_mps * peak_tau_for_domain(peak, domain))) < shell_sigma_m * 3.0) {
|
||||||
matched_depths.push_back(peak.z_app);
|
matched_depths.push_back(peak_depth_for_domain(peak, domain));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -883,7 +1052,16 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
|
|||||||
double shell_sigma_m
|
double shell_sigma_m
|
||||||
) -> std::pair<std::vector<RegionRecord>, std::vector<double>> {
|
) -> std::pair<std::vector<RegionRecord>, std::vector<double>> {
|
||||||
std::vector<RegionRecord> regions{};
|
std::vector<RegionRecord> 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 auto smoothed = gaussian_filter_2d(accumulator, grid.x_grid.size(), grid.z_grid.size(), kGaussianSigma);
|
||||||
const double smoothed_max = accumulator_max(smoothed);
|
const double smoothed_max = accumulator_max(smoothed);
|
||||||
if (!(smoothed_max > 0.0) || grid.x_grid.size() < 2U || grid.z_grid.size() < 2U) {
|
if (!(smoothed_max > 0.0) || grid.x_grid.size() < 2U || grid.z_grid.size() < 2U) {
|
||||||
@@ -968,7 +1146,17 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
|
|||||||
|
|
||||||
region.x_m = x_weight_sum / weight_sum;
|
region.x_m = x_weight_sum / weight_sum;
|
||||||
region.z_m = z_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<double>(component.size());
|
region.pixel_count = static_cast<double>(component.size());
|
||||||
regions.push_back(std::move(region));
|
regions.push_back(std::move(region));
|
||||||
}
|
}
|
||||||
@@ -998,9 +1186,10 @@ auto GprProcessor::process_collection(
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate_collection_trace_order(run_config, collection);
|
||||||
const auto background_mean = build_background_mean(previous_collections, selection, live_config);
|
const auto background_mean = build_background_mean(previous_collections, selection, live_config);
|
||||||
const auto traces_by_pair = collect_selected_traces(collection, selection, background_mean);
|
const auto selected_traces = collect_selected_traces(run_config, collection, selection, background_mean);
|
||||||
if (traces_by_pair.empty()) {
|
if (selected_traces.empty()) {
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1011,7 +1200,8 @@ auto GprProcessor::process_collection(
|
|||||||
|
|
||||||
std::unordered_map<PairKey, AscanResult> ascans_by_pair{};
|
std::unordered_map<PairKey, AscanResult> ascans_by_pair{};
|
||||||
double bandwidth_hz = 0.0;
|
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);
|
auto ascan = compute_ascan(trace, start_hz, stop_hz, velocity_mps);
|
||||||
if (ascan.amplitude.empty() || !(ascan.bandwidth_hz > 0.0)) {
|
if (ascan.amplitude.empty() || !(ascan.bandwidth_hz > 0.0)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1044,13 +1234,15 @@ auto GprProcessor::process_collection(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const double noise = median_copy(ascan.amplitude);
|
|
||||||
const auto min_index = lower_bound_index(ascan.depth_m, static_cast<double>(live_config.gpr_min_depth_m));
|
const auto min_index = lower_bound_index(ascan.depth_m, static_cast<double>(live_config.gpr_min_depth_m));
|
||||||
const auto max_index = lower_bound_index(ascan.depth_m, static_cast<double>(live_config.gpr_max_depth_m));
|
const auto max_index = lower_bound_index(ascan.depth_m, static_cast<double>(live_config.gpr_max_depth_m));
|
||||||
if (max_index <= min_index + 2U) {
|
if (max_index <= min_index + 2U) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const double noise =
|
||||||
|
median_copy(std::vector<double>(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 double z_step = std::max(ascan.depth_m[1] - ascan.depth_m[0], 1e-6);
|
||||||
const std::size_t min_distance = static_cast<std::size_t>(std::max(
|
const std::size_t min_distance = static_cast<std::size_t>(std::max(
|
||||||
4.0,
|
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 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 = attenuation_at_depth(tx_index, rx_index, z_app, selection.x_tx, selection.x_rx);
|
||||||
const double attenuation_norm =
|
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(
|
const double snr_comp = std::min(
|
||||||
snr_raw / (std::pow(attenuation_norm, static_cast<double>(live_config.gpr_comp_power)) + 1e-12),
|
snr_raw / (std::pow(attenuation_norm, static_cast<double>(live_config.gpr_comp_power)) + 1e-12),
|
||||||
kSnrCompMax
|
kSnrCompMax
|
||||||
@@ -1072,6 +1264,8 @@ auto GprProcessor::process_collection(
|
|||||||
peaks.push_back(PeakRecord{
|
peaks.push_back(PeakRecord{
|
||||||
.z_app = z_app,
|
.z_app = z_app,
|
||||||
.tau = ascan.time_s[peak_index],
|
.tau = ascan.time_s[peak_index],
|
||||||
|
.tau_corr = ascan.time_s[peak_index],
|
||||||
|
.z_corr = z_app,
|
||||||
.snr_raw = snr_raw,
|
.snr_raw = snr_raw,
|
||||||
.snr_comp = snr_comp,
|
.snr_comp = snr_comp,
|
||||||
});
|
});
|
||||||
@@ -1121,13 +1315,24 @@ auto GprProcessor::process_collection(
|
|||||||
return results;
|
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(
|
const auto [points, smoothed_accumulator] = clean_find_points(
|
||||||
grid,
|
grid,
|
||||||
peaks_by_pair,
|
peaks_by_pair,
|
||||||
selection.x_tx,
|
selection.x_tx,
|
||||||
selection.x_rx,
|
selection.x_rx,
|
||||||
velocity_mps,
|
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));
|
results.collection_payloads.push_back(build_image_payload("gpr_accumulator", grid.x_grid, grid.z_grid, smoothed_accumulator));
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ auto SweepOrchestrator::acquire_one_collection(
|
|||||||
collection.collection_id = collection_id;
|
collection.collection_id = collection_id;
|
||||||
collection.monotonic_ns = ipc::current_monotonic_ns();
|
collection.monotonic_ns = ipc::current_monotonic_ns();
|
||||||
collection.traces.reserve(config_.run_combos.size());
|
collection.traces.reserve(config_.run_combos.size());
|
||||||
|
collection.capture_start_ns = ipc::current_monotonic_ns();
|
||||||
bool interrupted = false;
|
bool interrupted = false;
|
||||||
|
|
||||||
for (const auto& combo : config_.run_combos) {
|
for (const auto& combo : config_.run_combos) {
|
||||||
@@ -170,8 +171,13 @@ auto SweepOrchestrator::acquire_one_collection(
|
|||||||
if (interrupted) {
|
if (interrupted) {
|
||||||
// Do not emit partial collections when stop was requested mid-cycle.
|
// Do not emit partial collections when stop was requested mid-cycle.
|
||||||
collection.traces.clear();
|
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;
|
return collection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ class AppWindowConfigMixin:
|
|||||||
comp_power=0.2,
|
comp_power=0.2,
|
||||||
start_freq_mhz=3000.0,
|
start_freq_mhz=3000.0,
|
||||||
stop_freq_mhz=6000.0,
|
stop_freq_mhz=6000.0,
|
||||||
|
speed_m_s=0.0,
|
||||||
|
look_angle_deg=0.0,
|
||||||
background_subtract_enabled=True,
|
background_subtract_enabled=True,
|
||||||
background_mean_count=10,
|
background_mean_count=10,
|
||||||
),
|
),
|
||||||
@@ -217,6 +219,8 @@ class AppWindowConfigMixin:
|
|||||||
comp_power=float(self._gpr_comp_power.value()),
|
comp_power=float(self._gpr_comp_power.value()),
|
||||||
start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
|
start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
|
||||||
stop_freq_mhz=float(self._gpr_stop_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_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
|
||||||
background_mean_count=int(self._gpr_background_mean_count.value()),
|
background_mean_count=int(self._gpr_background_mean_count.value()),
|
||||||
),
|
),
|
||||||
@@ -315,7 +319,7 @@ class AppWindowConfigMixin:
|
|||||||
details=self._capture_state_details(),
|
details=self._capture_state_details(),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if self._supervisor.is_running() or self._supervisor.is_processor_running():
|
if self._supervisor.is_running():
|
||||||
self._show_error(
|
self._show_error(
|
||||||
"Stop all pipeline processes before loading a config profile",
|
"Stop all pipeline processes before loading a config profile",
|
||||||
details=self._process_state_details(),
|
details=self._process_state_details(),
|
||||||
@@ -332,23 +336,26 @@ class AppWindowConfigMixin:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
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
|
except Exception as exc: # noqa: BLE001
|
||||||
self._show_exception("Failed to load config profile", exc)
|
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:
|
def _apply_loaded_profile(self, profile: GuiProfileModel, profile_path: Path) -> None:
|
||||||
"""Apply already parsed profile to GUI state without restarting the pipeline."""
|
"""Apply already parsed profile to GUI state without restarting the pipeline."""
|
||||||
config = profile.run_config.clone()
|
config = profile.run_config.clone()
|
||||||
@@ -395,6 +402,8 @@ class AppWindowConfigMixin:
|
|||||||
self._gpr_comp_power,
|
self._gpr_comp_power,
|
||||||
self._gpr_start_freq_mhz,
|
self._gpr_start_freq_mhz,
|
||||||
self._gpr_stop_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_subtract_enabled,
|
||||||
self._gpr_background_mean_count,
|
self._gpr_background_mean_count,
|
||||||
self._save_count,
|
self._save_count,
|
||||||
@@ -455,6 +464,8 @@ class AppWindowConfigMixin:
|
|||||||
self._gpr_comp_power.setValue(float(gui_state.processing.gpr.comp_power))
|
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_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_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(
|
self._gpr_background_subtract_enabled.setChecked(
|
||||||
bool(gui_state.processing.gpr.background_subtract_enabled)
|
bool(gui_state.processing.gpr.background_subtract_enabled)
|
||||||
)
|
)
|
||||||
@@ -567,6 +578,8 @@ class AppWindowConfigMixin:
|
|||||||
gpr_comp_power=float(self._gpr_comp_power.value()),
|
gpr_comp_power=float(self._gpr_comp_power.value()),
|
||||||
gpr_start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
|
gpr_start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
|
||||||
gpr_stop_freq_mhz=float(self._gpr_stop_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_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
|
||||||
gpr_background_mean_count=int(self._gpr_background_mean_count.value()),
|
gpr_background_mean_count=int(self._gpr_background_mean_count.value()),
|
||||||
history_command_seq=int(self._history_command_seq),
|
history_command_seq=int(self._history_command_seq),
|
||||||
@@ -640,6 +653,8 @@ class AppWindowConfigMixin:
|
|||||||
f"outputs={self._gpr_output_positions_input.text().strip() or '<all>'}, "
|
f"outputs={self._gpr_output_positions_input.text().strip() or '<all>'}, "
|
||||||
f"depth={self._gpr_min_depth_m.value():g}..{self._gpr_max_depth_m.value():g} m, "
|
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"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"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, "
|
||||||
f"mean_count={self._gpr_background_mean_count.value()})"
|
f"mean_count={self._gpr_background_mean_count.value()})"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ class AppWindowPipelineMixin:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
processor_was_running = self._supervisor.is_processor_running()
|
processor_was_running = self._supervisor.is_processor_running()
|
||||||
|
config = self._build_config()
|
||||||
if not processor_was_running:
|
if not processor_was_running:
|
||||||
self._reset_runtime_history()
|
self._reset_runtime_history()
|
||||||
|
|
||||||
config = self._build_config()
|
|
||||||
self._validate_processing_mode_constraints(config)
|
self._validate_processing_mode_constraints(config)
|
||||||
run_signature = self._build_run_history_signature(config)
|
run_signature = self._build_run_history_signature(config)
|
||||||
radar_key = self._radar_key(config)
|
radar_key = self._radar_key(config)
|
||||||
|
|||||||
@@ -191,6 +191,18 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._gpr_stop_freq_mhz.setSingleStep(10.0)
|
owner._gpr_stop_freq_mhz.setSingleStep(10.0)
|
||||||
owner._gpr_stop_freq_mhz.setValue(float(gpr_live_defaults.stop_freq_mhz))
|
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 = QCheckBox("Subtract mean of previous collections")
|
||||||
owner._gpr_background_subtract_enabled.setChecked(bool(gpr_live_defaults.background_subtract_enabled))
|
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("Comp power", owner._gpr_comp_power)
|
||||||
gpr_form.addRow("Start MHz", owner._gpr_start_freq_mhz)
|
gpr_form.addRow("Start MHz", owner._gpr_start_freq_mhz)
|
||||||
gpr_form.addRow("Stop MHz", owner._gpr_stop_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(owner._gpr_background_subtract_enabled)
|
||||||
gpr_form.addRow("Mean count", owner._gpr_background_mean_count)
|
gpr_form.addRow("Mean count", owner._gpr_background_mean_count)
|
||||||
owner._processing_mode_pages.addWidget(gpr_page)
|
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_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_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_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_subtract_enabled.toggled.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._gpr_background_mean_count.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._gpr_background_mean_count.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ class SweepCollection:
|
|||||||
collection_id: int
|
collection_id: int
|
||||||
monotonic_ns: int
|
monotonic_ns: int
|
||||||
traces: list[TraceData] = field(default_factory=list)
|
traces: list[TraceData] = field(default_factory=list)
|
||||||
|
capture_start_ns: int = 0
|
||||||
|
capture_end_ns: int = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|||||||
@@ -196,6 +196,18 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
|||||||
gui.processing.gpr.stop_freq_mhz,
|
gui.processing.gpr.stop_freq_mhz,
|
||||||
"gui.processing.gpr",
|
"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(
|
background_subtract_enabled=_optional_bool(
|
||||||
gpr_object,
|
gpr_object,
|
||||||
"background_subtract_enabled",
|
"background_subtract_enabled",
|
||||||
@@ -288,6 +300,8 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
|||||||
"comp_power": gui.processing.gpr.comp_power,
|
"comp_power": gui.processing.gpr.comp_power,
|
||||||
"start_freq_mhz": gui.processing.gpr.start_freq_mhz,
|
"start_freq_mhz": gui.processing.gpr.start_freq_mhz,
|
||||||
"stop_freq_mhz": gui.processing.gpr.stop_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_subtract_enabled": gui.processing.gpr.background_subtract_enabled,
|
||||||
"background_mean_count": gui.processing.gpr.background_mean_count,
|
"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
|
return payload
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ class GuiGprStateModel:
|
|||||||
comp_power: float = 0.2
|
comp_power: float = 0.2
|
||||||
start_freq_mhz: float = 3000.0
|
start_freq_mhz: float = 3000.0
|
||||||
stop_freq_mhz: float = 6000.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_subtract_enabled: bool = True
|
||||||
background_mean_count: int = 10
|
background_mean_count: int = 10
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ class ProcessingLiveConfig:
|
|||||||
gpr_comp_power: float = 0.2
|
gpr_comp_power: float = 0.2
|
||||||
gpr_start_freq_mhz: float = 3000.0
|
gpr_start_freq_mhz: float = 3000.0
|
||||||
gpr_stop_freq_mhz: float = 6000.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_subtract_enabled: bool = True
|
||||||
gpr_background_mean_count: int = 10
|
gpr_background_mean_count: int = 10
|
||||||
history_command_seq: int = 0
|
history_command_seq: int = 0
|
||||||
@@ -68,6 +70,8 @@ class ProcessingLiveConfig:
|
|||||||
"gpr_comp_power": float(self.gpr_comp_power),
|
"gpr_comp_power": float(self.gpr_comp_power),
|
||||||
"gpr_start_freq_mhz": float(self.gpr_start_freq_mhz),
|
"gpr_start_freq_mhz": float(self.gpr_start_freq_mhz),
|
||||||
"gpr_stop_freq_mhz": float(self.gpr_stop_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_subtract_enabled": bool(self.gpr_background_subtract_enabled),
|
||||||
"gpr_background_mean_count": int(self.gpr_background_mean_count),
|
"gpr_background_mean_count": int(self.gpr_background_mean_count),
|
||||||
"history_command_seq": int(self.history_command_seq),
|
"history_command_seq": int(self.history_command_seq),
|
||||||
|
|||||||
@@ -48,3 +48,7 @@ class ByteCursor:
|
|||||||
data = self.payload[self.offset : self.offset + size]
|
data = self.payload[self.offset : self.offset + size]
|
||||||
self.offset += size
|
self.offset += size
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
def remaining_bytes(self) -> int:
|
||||||
|
"""Return unread byte count."""
|
||||||
|
return len(self.payload) - self.offset
|
||||||
|
|||||||
@@ -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:
|
def decode_result_collection(payload: bytes) -> ResultCollection:
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes
|
|||||||
_write_interleaved_complex(buffer, s11)
|
_write_interleaved_complex(buffer, s11)
|
||||||
_write_interleaved_complex(buffer, s21)
|
_write_interleaved_complex(buffer, s21)
|
||||||
|
|
||||||
|
buffer.extend(
|
||||||
|
struct.pack(
|
||||||
|
"<QQ",
|
||||||
|
int(collection.capture_start_ns),
|
||||||
|
int(collection.capture_end_ns),
|
||||||
|
)
|
||||||
|
)
|
||||||
return bytes(buffer)
|
return bytes(buffer)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ def save_trace_history_binary(stage_dir: Path, history: list[SweepCollection], m
|
|||||||
{
|
{
|
||||||
"collection_id": collection.collection_id,
|
"collection_id": collection.collection_id,
|
||||||
"monotonic_ns": collection.monotonic_ns,
|
"monotonic_ns": collection.monotonic_ns,
|
||||||
|
"capture_start_ns": int(collection.capture_start_ns),
|
||||||
|
"capture_end_ns": int(collection.capture_end_ns),
|
||||||
"trace_count": len(collection.traces),
|
"trace_count": len(collection.traces),
|
||||||
},
|
},
|
||||||
indent=2,
|
indent=2,
|
||||||
@@ -178,6 +180,8 @@ def save_trace_history_numpy(stage_dir: Path, history: list[SweepCollection]) ->
|
|||||||
{
|
{
|
||||||
"collection_id": int(collection.collection_id),
|
"collection_id": int(collection.collection_id),
|
||||||
"monotonic_ns": int(collection.monotonic_ns),
|
"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),
|
"trace_count": len(collection.traces),
|
||||||
"traces": traces_meta,
|
"traces": traces_meta,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ class NpzStore(StoreApi):
|
|||||||
meta = {
|
meta = {
|
||||||
"collection_id": int(collection.collection_id),
|
"collection_id": int(collection.collection_id),
|
||||||
"monotonic_ns": int(collection.monotonic_ns),
|
"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,
|
"combos": combo_records,
|
||||||
}
|
}
|
||||||
meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
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"]),
|
collection_id=int(meta["collection_id"]),
|
||||||
monotonic_ns=int(meta["monotonic_ns"]),
|
monotonic_ns=int(meta["monotonic_ns"]),
|
||||||
traces=traces,
|
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]:
|
def list_sets(self, kind: str, radar_key: str) -> list[str]:
|
||||||
|
|||||||
Reference in New Issue
Block a user