added GPR
This commit is contained in:
+653
@@ -0,0 +1,653 @@
|
|||||||
|
"""
|
||||||
|
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 scipy.signal import find_peaks
|
||||||
|
from scipy.ndimage import gaussian_filter, label
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
### Изменяемые параметры
|
||||||
|
INPUT_IDX = [0,1,2,3]
|
||||||
|
OUTPUT_IDX = [0,3]
|
||||||
|
|
||||||
|
MIN_DEPTH = 2.0 # [м] пропустить прямую волну
|
||||||
|
MAX_DEPTH = 14.0
|
||||||
|
COMP_POWER = 0.2 # степень компенсации затухания
|
||||||
|
|
||||||
|
F_START = 30*1e8 # Нижняя частота
|
||||||
|
F_STOP = 6*1e9 # Верхняя частота
|
||||||
|
|
||||||
|
# Вычитание среднего фона (background removal)
|
||||||
|
|
||||||
|
BG_SUBTRACT = True #True/False
|
||||||
|
BG_PATH = Path('/Users/ivan_root/Downloads/Telegram_dwnld/03-13-measure/2_cylinder_dif_side_and_mushrooms/preprocessed')
|
||||||
|
|
||||||
|
DATA_PATH = Path('/Users/ivan_root/Downloads/Telegram_dwnld/03-13-measure/2_cylinder_dif_side_and_mushrooms/preprocessed/0006_id1_ns1875848749103')
|
||||||
|
|
||||||
|
|
||||||
|
###Конфиги
|
||||||
|
|
||||||
|
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 = 3.0 # минимальный SNR пика
|
||||||
|
SNR_COMP_MAX = 20.0 # Верхний порог для компенсированного значения SNR
|
||||||
|
|
||||||
|
# Параметр: максимальное число объектов для поиска
|
||||||
|
MAX_OBJECTS = 15 # <-- настройте под вашу задачу
|
||||||
|
|
||||||
|
# Границы сетки аккумулятора
|
||||||
|
x_min, x_max = x_tx.min() - 2.0, x_tx.max() + 2.0 # [м]
|
||||||
|
z_min, z_max = 0.10, 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)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# Накопитель: для каждой пары суммируем 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]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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 "без вычитания фона"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Общая ось z для визуализации и поиска пиков
|
||||||
|
# (берём максимальный диапазон по всем парам)
|
||||||
|
z_h = Z_h[list(Z_h.keys())[0]] # все пары дают одинаковую ось, если freq совпадают
|
||||||
|
t_h = T_h[list(T_h.keys())[0]]
|
||||||
|
|
||||||
|
|
||||||
|
# 4. ВИЗУАЛИЗАЦИЯ A-СКАНОВ
|
||||||
|
|
||||||
|
|
||||||
|
def plot_ascans(A, z_h, n_tx, n_rx):
|
||||||
|
"""Отображение всех A-сканов"""
|
||||||
|
fig, axes = plt.subplots(n_tx, n_rx, figsize=(3*n_rx, 3*n_tx),
|
||||||
|
sharex=True, sharey=True)
|
||||||
|
|
||||||
|
if n_tx == 1:
|
||||||
|
axes = axes.reshape(1, -1)
|
||||||
|
if n_rx == 1:
|
||||||
|
axes = axes.reshape(-1, 1)
|
||||||
|
|
||||||
|
for i in range(n_tx):
|
||||||
|
for j in range(n_rx):
|
||||||
|
ax = axes[i, j]
|
||||||
|
if (i, j) in A:
|
||||||
|
ax.plot(z_h, A[(i, j)], 'b-', lw=0.8)
|
||||||
|
ax.set_title(f'Tx{i} → Rx{j}', fontsize=10)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
else:
|
||||||
|
ax.set_visible(False)
|
||||||
|
|
||||||
|
axes[-1, 0].set_xlabel('Глубина z [м]')
|
||||||
|
axes[0, 0].set_ylabel('Амплитуда')
|
||||||
|
fig.suptitle('A-сканы всех пар Tx-Rx', fontsize=12)
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
|
||||||
|
def plot_bscan(A, z_h, x_tx, x_rx):
|
||||||
|
"""B-скан: все A-сканы рядом, отсортированные по виртуальной позиции"""
|
||||||
|
pairs = sorted(A.keys(), key=lambda p: (x_tx[p[0]] + x_rx[p[1]]) / 2)
|
||||||
|
|
||||||
|
bscan = np.array([A[p] for p in pairs]).T
|
||||||
|
x_virt = [(x_tx[p[0]] + x_rx[p[1]]) / 2 for p in pairs]
|
||||||
|
|
||||||
|
plt.figure(figsize=(10, 6))
|
||||||
|
plt.imshow(bscan, aspect='auto', origin='lower',
|
||||||
|
extent=[min(x_virt), max(x_virt), z_h[0], z_h[-1]],
|
||||||
|
cmap='jet')
|
||||||
|
plt.colorbar(label='Амплитуда')
|
||||||
|
plt.xlabel('Виртуальная позиция X [м]')
|
||||||
|
plt.ylabel('Глубина Z [м]')
|
||||||
|
plt.title('B-скан (все пары)')
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════
|
||||||
|
# 5. ДЕТЕКТИРОВАНИЕ ПИКОВ
|
||||||
|
# ══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
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)]
|
||||||
|
noise = np.median(ascan)
|
||||||
|
i_min = np.searchsorted(z_h_ij, MIN_DEPTH)
|
||||||
|
i_max = np.searchsorted(z_h_ij, MAX_DEPTH)
|
||||||
|
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, 2.0) # Референсная глубина - 2м
|
||||||
|
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)}
|
||||||
|
n_total = sum(len(v2) for v2 in peaks.values())
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════
|
||||||
|
# 5. АККУМУЛЯТОР
|
||||||
|
# ══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def build_accumulator(exclude_z_ranges):
|
||||||
|
"""
|
||||||
|
Для каждого пика строим гауссову оболочку вокруг эллипса.
|
||||||
|
"""
|
||||||
|
acc = np.zeros_like(XX)
|
||||||
|
for i in range(N_tx):
|
||||||
|
for j in range(N_rx):
|
||||||
|
for pk in peaks[(i, j)]:
|
||||||
|
if any(lo <= pk['z_app'] <= hi for lo, hi in exclude_z_ranges):
|
||||||
|
continue
|
||||||
|
R_total = v * pk['tau']
|
||||||
|
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(x_est, z_est, exclude_z_ranges):
|
||||||
|
"""
|
||||||
|
Score = количество пар, чей эллипс проходит
|
||||||
|
через точку (x_est, z_est) с невязкой δ < 3σ.
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
for i in range(N_tx):
|
||||||
|
for j in range(N_rx):
|
||||||
|
for pk in peaks[(i, j)]:
|
||||||
|
if any(lo <= pk['z_app'] <= 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']) < SHELL_SIGMA * 6:
|
||||||
|
count += 1
|
||||||
|
break # одна пара — один голос
|
||||||
|
return count
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════
|
||||||
|
# 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]
|
||||||
|
|
||||||
|
|
||||||
|
def clean_find(n_search=10, suppress_r_cm=7, thresh_frac=0.05):
|
||||||
|
"""
|
||||||
|
CLEAN-итерация для точечных объектов.
|
||||||
|
"""
|
||||||
|
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 = []
|
||||||
|
acc_initial = build_accumulator([])
|
||||||
|
|
||||||
|
for step in range(n_search):
|
||||||
|
acc = build_accumulator(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(x_est, z_est, excl_z)
|
||||||
|
found.append({'x': x_est, 'z': z_est, 'score': score})
|
||||||
|
|
||||||
|
# Пики, соответствующие этому объекту
|
||||||
|
matched = [pk['z_app']
|
||||||
|
for i in range(N_tx) for j in range(N_rx)
|
||||||
|
for pk in peaks[(i, j)]
|
||||||
|
if not any(lo<=pk['z_app']<=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']) < SHELL_SIGMA * 3] #Возможны изменения
|
||||||
|
|
||||||
|
if matched:
|
||||||
|
margin = SHELL_SIGMA * 1.0
|
||||||
|
excl_z.append((min(matched) - margin, max(matched) + margin))
|
||||||
|
|
||||||
|
return found, acc_initial
|
||||||
|
|
||||||
|
|
||||||
|
def extended_find(thresh_frac=0.75, min_area_cm2=2.0):
|
||||||
|
"""
|
||||||
|
Режим для протяжённых объектов (труба, плита и т.п.).
|
||||||
|
"""
|
||||||
|
acc = build_accumulator([])
|
||||||
|
acc_s = gaussian_filter(acc, sigma=3)
|
||||||
|
binary = acc_s > thresh_frac * acc_s.max()
|
||||||
|
|
||||||
|
dx = (x_grid[1]-x_grid[0])*100
|
||||||
|
dz = (z_grid[1]-z_grid[0])*100
|
||||||
|
min_pix = int(min_area_cm2 / (dx * dz))
|
||||||
|
|
||||||
|
labeled, n = label(binary)
|
||||||
|
regions = []
|
||||||
|
for k in range(1, n+1):
|
||||||
|
mask = labeled == k
|
||||||
|
if mask.sum() < min_pix:
|
||||||
|
continue
|
||||||
|
w = acc_s[mask]
|
||||||
|
xs = XX[mask]; zs = ZZ[mask]
|
||||||
|
xc = (xs * w).sum() / w.sum()
|
||||||
|
zc = (zs * w).sum() / w.sum()
|
||||||
|
score = count_agreeing_ellipses(xc, zc, [])
|
||||||
|
regions.append({'x': xc, 'z': zc, 'score': score,
|
||||||
|
'mask': mask, 'n_pix': mask.sum()})
|
||||||
|
return regions, acc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if MODE == 'point':
|
||||||
|
found, accum = clean_find(n_search=MAX_OBJECTS)
|
||||||
|
# print(f"найдено {len(found)} объектов.")
|
||||||
|
# for k, obj in enumerate(found):
|
||||||
|
# print(f" [{k+1}] x={obj['x']*100:+.1f} см, "
|
||||||
|
# f"z={obj['z']*100:.1f} см, "
|
||||||
|
# f"score={obj['score']}/{N_pairs}")
|
||||||
|
else:
|
||||||
|
regions, accum = extended_find()
|
||||||
|
found = regions
|
||||||
|
# print(f"найдено {len(regions)} регионов.")
|
||||||
|
# for k, r in enumerate(regions):
|
||||||
|
# print(f" [{k+1}] центр x={r['x']*100:+.1f} см, "
|
||||||
|
# f"z={r['z']*100:.1f} см, score={r['score']}/{N_pairs}")
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════
|
||||||
|
# 7. ГРАФИКИ
|
||||||
|
# ══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ─── График 2: карта накопления ────────────────────
|
||||||
|
fig, ax = plt.subplots(figsize=(12, 7))
|
||||||
|
acc_s = gaussian_filter(accum, sigma=3)
|
||||||
|
im = ax.imshow(acc_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_s.max()*0.35, vmax=acc_s.max()*0.95)
|
||||||
|
plt.colorbar(im, ax=ax, label='Накопленный вес (SNR_comp × гауссова оболочка)')
|
||||||
|
|
||||||
|
# Позиции антенн
|
||||||
|
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:
|
||||||
|
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))
|
||||||
|
|
||||||
|
if MODE == 'extended':
|
||||||
|
for r in regions:
|
||||||
|
ax.contour(x_grid*100, z_grid*100, r['mask'].astype(float),
|
||||||
|
levels=[0.5], colors=['cyan'], linewidths=[1.2])
|
||||||
|
|
||||||
|
ax.plot([], [], 'wD', ms=9, markeredgecolor='k', mew=1.2, label='Найденные объекты')
|
||||||
|
ax.set_xlabel("X [см]"); ax.set_ylabel("Глубина Z [см]")
|
||||||
|
ax.set_title("Карта накопления эллипсов\n"
|
||||||
|
f"score = число пар из {N_pairs}, чей эллипс проходит через точку")
|
||||||
|
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()
|
||||||
|
plt.show()
|
||||||
@@ -45,6 +45,7 @@ PREPROC_SOURCES := \
|
|||||||
|
|
||||||
PROCESSOR_SOURCES := \
|
PROCESSOR_SOURCES := \
|
||||||
data_acq_and_processing/processing/processors/src/bscan_processor.cpp \
|
data_acq_and_processing/processing/processors/src/bscan_processor.cpp \
|
||||||
|
data_acq_and_processing/processing/processors/src/gpr_processor.cpp \
|
||||||
data_acq_and_processing/processing/processors/src/passthrough_processor.cpp \
|
data_acq_and_processing/processing/processors/src/passthrough_processor.cpp \
|
||||||
data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp \
|
data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp \
|
||||||
data_acq_and_processing/processing/data_processor/src/data_processor.cpp \
|
data_acq_and_processing/processing/data_processor/src/data_processor.cpp \
|
||||||
|
|||||||
@@ -81,6 +81,26 @@ struct PreprocessConfig {
|
|||||||
std::string reference_bundle_path{};
|
std::string reference_bundle_path{};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct GprTxGeometry {
|
||||||
|
// Transmitter geometry keyed by output switch position.
|
||||||
|
std::uint32_t output_pos = 0;
|
||||||
|
float x_m = 0.0F;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GprRxGeometry {
|
||||||
|
// Receiver geometry keyed by input switch position.
|
||||||
|
std::uint32_t input_pos = 0;
|
||||||
|
float x_m = 0.0F;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GprConfig {
|
||||||
|
// Stable collection-level GPR processing settings.
|
||||||
|
std::string mode = "point";
|
||||||
|
float relative_permittivity = 1.0F;
|
||||||
|
std::vector<GprTxGeometry> tx_geometry{};
|
||||||
|
std::vector<GprRxGeometry> rx_geometry{};
|
||||||
|
};
|
||||||
|
|
||||||
struct RunConfig {
|
struct RunConfig {
|
||||||
// Single configuration object shared by all C++ processes.
|
// Single configuration object shared by all C++ processes.
|
||||||
RadarConfig radar{};
|
RadarConfig radar{};
|
||||||
@@ -89,6 +109,7 @@ struct RunConfig {
|
|||||||
RingsConfig rings{};
|
RingsConfig rings{};
|
||||||
RuntimeConfig runtime{};
|
RuntimeConfig runtime{};
|
||||||
PreprocessConfig preprocess{};
|
PreprocessConfig preprocess{};
|
||||||
|
GprConfig gpr{};
|
||||||
std::vector<radar::ipc::ComboKey> run_combos{};
|
std::vector<radar::ipc::ComboKey> run_combos{};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
#include <limits>
|
#include <limits>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
@@ -214,6 +216,69 @@ void validate_combo_key(const ipc::ComboKey& key, const RunConfig& config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void validate_gpr_config(const GprConfig& config, const RunConfig& run_config) {
|
||||||
|
if (config.mode != "point" && config.mode != "extended") {
|
||||||
|
throw std::runtime_error("gpr.mode must be either 'point' or 'extended'");
|
||||||
|
}
|
||||||
|
if (!(config.relative_permittivity > 0.0F)) {
|
||||||
|
throw std::runtime_error("gpr.relative_permittivity must be > 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unordered_set<std::uint32_t> seen_tx_positions{};
|
||||||
|
for (const auto& entry : config.tx_geometry) {
|
||||||
|
if (entry.output_pos >= run_config.output_switch.positions) {
|
||||||
|
throw std::runtime_error("gpr.tx_geometry output_pos is out of range");
|
||||||
|
}
|
||||||
|
if (!seen_tx_positions.insert(entry.output_pos).second) {
|
||||||
|
throw std::runtime_error("gpr.tx_geometry contains duplicate output_pos");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unordered_set<std::uint32_t> seen_rx_positions{};
|
||||||
|
for (const auto& entry : config.rx_geometry) {
|
||||||
|
if (entry.input_pos >= run_config.input_switch.positions) {
|
||||||
|
throw std::runtime_error("gpr.rx_geometry input_pos is out of range");
|
||||||
|
}
|
||||||
|
if (!seen_rx_positions.insert(entry.input_pos).second) {
|
||||||
|
throw std::runtime_error("gpr.rx_geometry contains duplicate input_pos");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto parse_gpr_config(const Json& object, const RunConfig& run_config) -> GprConfig {
|
||||||
|
const auto* gpr_obj = as_object(object, "gpr");
|
||||||
|
GprConfig config{};
|
||||||
|
config.mode = optional_string(*gpr_obj, "mode", "point");
|
||||||
|
config.relative_permittivity = optional_f32(*gpr_obj, "relative_permittivity", 1.0F);
|
||||||
|
|
||||||
|
if (const auto* tx_value = optional_field(*gpr_obj, "tx_geometry"); tx_value != nullptr) {
|
||||||
|
const auto* tx_array = as_array(*tx_value, "gpr.tx_geometry");
|
||||||
|
config.tx_geometry.reserve(tx_array->size());
|
||||||
|
for (const auto& entry_value : *tx_array) {
|
||||||
|
const auto* entry_obj = as_object(entry_value, "gpr.tx_geometry[]");
|
||||||
|
GprTxGeometry entry{};
|
||||||
|
entry.output_pos = optional_u32(*entry_obj, "output_pos", 0U);
|
||||||
|
entry.x_m = optional_f32(*entry_obj, "x_m", 0.0F);
|
||||||
|
config.tx_geometry.push_back(std::move(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (const auto* rx_value = optional_field(*gpr_obj, "rx_geometry"); rx_value != nullptr) {
|
||||||
|
const auto* rx_array = as_array(*rx_value, "gpr.rx_geometry");
|
||||||
|
config.rx_geometry.reserve(rx_array->size());
|
||||||
|
for (const auto& entry_value : *rx_array) {
|
||||||
|
const auto* entry_obj = as_object(entry_value, "gpr.rx_geometry[]");
|
||||||
|
GprRxGeometry entry{};
|
||||||
|
entry.input_pos = optional_u32(*entry_obj, "input_pos", 0U);
|
||||||
|
entry.x_m = optional_f32(*entry_obj, "x_m", 0.0F);
|
||||||
|
config.rx_geometry.push_back(std::move(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_gpr_config(config, run_config);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
[[nodiscard]] auto parse_switch_config(
|
[[nodiscard]] auto parse_switch_config(
|
||||||
const Json& object,
|
const Json& object,
|
||||||
const std::string& default_name,
|
const std::string& default_name,
|
||||||
@@ -379,6 +444,12 @@ auto load_run_config(const std::string& path) -> RunConfig {
|
|||||||
config.preprocess.reference_bundle_path = optional_string(*preprocess_obj, "reference_bundle_path", "");
|
config.preprocess.reference_bundle_path = optional_string(*preprocess_obj, "reference_bundle_path", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (const auto* gpr_value = optional_field(*root_obj, "gpr"); gpr_value != nullptr) {
|
||||||
|
config.gpr = parse_gpr_config(*gpr_value, config);
|
||||||
|
} else {
|
||||||
|
validate_gpr_config(config.gpr, config);
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const auto* rings_obj = as_object(required_field(*root_obj, "rings"), "rings");
|
const auto* rings_obj = as_object(required_field(*root_obj, "rings"), "rings");
|
||||||
config.rings.raw = parse_ring_endpoint(*rings_obj, "raw", config.rings.raw);
|
config.rings.raw = parse_ring_endpoint(*rings_obj, "raw", config.rings.raw);
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ using PreprocessedCollection = RawSweepCollection;
|
|||||||
enum class ResultKind : std::uint8_t {
|
enum class ResultKind : std::uint8_t {
|
||||||
TraceComplex = 1,
|
TraceComplex = 1,
|
||||||
ScalarF32 = 2,
|
ScalarF32 = 2,
|
||||||
|
ImageF32 = 3,
|
||||||
|
TableF32 = 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct ResultPayload {
|
struct ResultPayload {
|
||||||
@@ -60,6 +62,13 @@ struct ResultPayload {
|
|||||||
std::vector<Complex32> trace{};
|
std::vector<Complex32> trace{};
|
||||||
// Valid for ScalarF32 payloads.
|
// Valid for ScalarF32 payloads.
|
||||||
float scalar_value = 0.0F;
|
float scalar_value = 0.0F;
|
||||||
|
// Valid for ImageF32 payloads.
|
||||||
|
std::vector<float> image_x_axis{};
|
||||||
|
std::vector<float> image_y_axis{};
|
||||||
|
std::vector<float> image_values{};
|
||||||
|
// Valid for TableF32 payloads.
|
||||||
|
std::uint32_t table_columns = 0;
|
||||||
|
std::vector<float> table_values{};
|
||||||
};
|
};
|
||||||
|
|
||||||
struct ResultBlock {
|
struct ResultBlock {
|
||||||
@@ -70,6 +79,7 @@ struct ResultBlock {
|
|||||||
struct ResultCollection {
|
struct ResultCollection {
|
||||||
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::vector<ResultPayload> collection_payloads{};
|
||||||
std::vector<ResultBlock> blocks{};
|
std::vector<ResultBlock> blocks{};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,75 @@ auto read_trace_result_payload(BinaryReader& reader, ResultPayload* payload) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void write_image_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
|
||||||
|
const auto x_count = checked_count_to_u32(payload.image_x_axis.size(), "Result image x axis point count");
|
||||||
|
const auto y_count = checked_count_to_u32(payload.image_y_axis.size(), "Result image y axis point count");
|
||||||
|
const std::size_t expected_value_count = static_cast<std::size_t>(x_count) * static_cast<std::size_t>(y_count);
|
||||||
|
if (payload.image_values.size() != expected_value_count) {
|
||||||
|
throw std::runtime_error("Result image payload has inconsistent axis/value sizes");
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.write(x_count);
|
||||||
|
writer.write(y_count);
|
||||||
|
for (const auto value : payload.image_x_axis) {
|
||||||
|
writer.write(value);
|
||||||
|
}
|
||||||
|
for (const auto value : payload.image_y_axis) {
|
||||||
|
writer.write(value);
|
||||||
|
}
|
||||||
|
for (const auto value : payload.image_values) {
|
||||||
|
writer.write(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto read_image_result_payload(BinaryReader& reader, ResultPayload* payload) -> void {
|
||||||
|
const auto x_count = reader.read<std::uint32_t>();
|
||||||
|
const auto y_count = reader.read<std::uint32_t>();
|
||||||
|
|
||||||
|
payload->image_x_axis.reserve(x_count);
|
||||||
|
payload->image_y_axis.reserve(y_count);
|
||||||
|
payload->image_values.reserve(static_cast<std::size_t>(x_count) * static_cast<std::size_t>(y_count));
|
||||||
|
|
||||||
|
for (std::uint32_t index = 0; index < x_count; ++index) {
|
||||||
|
payload->image_x_axis.push_back(reader.read<float>());
|
||||||
|
}
|
||||||
|
for (std::uint32_t index = 0; index < y_count; ++index) {
|
||||||
|
payload->image_y_axis.push_back(reader.read<float>());
|
||||||
|
}
|
||||||
|
for (std::size_t index = 0; index < static_cast<std::size_t>(x_count) * static_cast<std::size_t>(y_count); ++index) {
|
||||||
|
payload->image_values.push_back(reader.read<float>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void write_table_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
|
||||||
|
const auto column_count = payload.table_columns;
|
||||||
|
if (column_count == 0U && !payload.table_values.empty()) {
|
||||||
|
throw std::runtime_error("Result table payload must declare table_columns when values are present");
|
||||||
|
}
|
||||||
|
if (column_count != 0U && (payload.table_values.size() % column_count) != 0U) {
|
||||||
|
throw std::runtime_error("Result table payload has inconsistent column/value sizes");
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::uint32_t row_count =
|
||||||
|
column_count == 0U ? 0U : checked_count_to_u32(payload.table_values.size() / column_count, "Result table row count");
|
||||||
|
|
||||||
|
writer.write(column_count);
|
||||||
|
writer.write(row_count);
|
||||||
|
for (const auto value : payload.table_values) {
|
||||||
|
writer.write(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto read_table_result_payload(BinaryReader& reader, ResultPayload* payload) -> void {
|
||||||
|
const auto column_count = reader.read<std::uint32_t>();
|
||||||
|
const auto row_count = reader.read<std::uint32_t>();
|
||||||
|
payload->table_columns = column_count;
|
||||||
|
payload->table_values.reserve(static_cast<std::size_t>(column_count) * static_cast<std::size_t>(row_count));
|
||||||
|
for (std::size_t index = 0; index < static_cast<std::size_t>(column_count) * static_cast<std::size_t>(row_count); ++index) {
|
||||||
|
payload->table_values.push_back(reader.read<float>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void write_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
|
void write_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
|
||||||
writer.write(static_cast<std::uint8_t>(payload.kind));
|
writer.write(static_cast<std::uint8_t>(payload.kind));
|
||||||
writer.write_string(payload.processing_name);
|
writer.write_string(payload.processing_name);
|
||||||
@@ -211,6 +280,12 @@ void write_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
|
|||||||
case ResultKind::ScalarF32:
|
case ResultKind::ScalarF32:
|
||||||
writer.write(payload.scalar_value);
|
writer.write(payload.scalar_value);
|
||||||
return;
|
return;
|
||||||
|
case ResultKind::ImageF32:
|
||||||
|
write_image_result_payload(writer, payload);
|
||||||
|
return;
|
||||||
|
case ResultKind::TableF32:
|
||||||
|
write_table_result_payload(writer, payload);
|
||||||
|
return;
|
||||||
default:
|
default:
|
||||||
throw std::runtime_error("Unsupported result payload kind");
|
throw std::runtime_error("Unsupported result payload kind");
|
||||||
}
|
}
|
||||||
@@ -228,6 +303,12 @@ void write_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
|
|||||||
case ResultKind::ScalarF32:
|
case ResultKind::ScalarF32:
|
||||||
payload.scalar_value = reader.read<float>();
|
payload.scalar_value = reader.read<float>();
|
||||||
return payload;
|
return payload;
|
||||||
|
case ResultKind::ImageF32:
|
||||||
|
read_image_result_payload(reader, &payload);
|
||||||
|
return payload;
|
||||||
|
case ResultKind::TableF32:
|
||||||
|
read_table_result_payload(reader, &payload);
|
||||||
|
return payload;
|
||||||
default:
|
default:
|
||||||
throw std::runtime_error("Unsupported result payload kind in stream");
|
throw std::runtime_error("Unsupported result payload kind in stream");
|
||||||
}
|
}
|
||||||
@@ -296,8 +377,12 @@ auto serialize_result_collection(const ResultCollection& collection) -> std::vec
|
|||||||
writer.write(kResultCollectionMagic);
|
writer.write(kResultCollectionMagic);
|
||||||
writer.write(collection.collection_id);
|
writer.write(collection.collection_id);
|
||||||
writer.write(collection.monotonic_ns);
|
writer.write(collection.monotonic_ns);
|
||||||
|
writer.write(checked_count_to_u32(collection.collection_payloads.size(), "Collection payload count"));
|
||||||
writer.write(checked_count_to_u32(collection.blocks.size(), "Result block count"));
|
writer.write(checked_count_to_u32(collection.blocks.size(), "Result block count"));
|
||||||
|
|
||||||
|
for (const auto& payload : collection.collection_payloads) {
|
||||||
|
write_result_payload(writer, payload);
|
||||||
|
}
|
||||||
for (const auto& block : collection.blocks) {
|
for (const auto& block : collection.blocks) {
|
||||||
write_result_block(writer, block);
|
write_result_block(writer, block);
|
||||||
}
|
}
|
||||||
@@ -317,7 +402,13 @@ auto deserialize_result_collection(std::span<const std::uint8_t> bytes) -> Resul
|
|||||||
collection.collection_id = reader.read<std::uint64_t>();
|
collection.collection_id = reader.read<std::uint64_t>();
|
||||||
collection.monotonic_ns = reader.read<std::uint64_t>();
|
collection.monotonic_ns = reader.read<std::uint64_t>();
|
||||||
|
|
||||||
|
const auto collection_payload_count = reader.read<std::uint32_t>();
|
||||||
const auto block_count = reader.read<std::uint32_t>();
|
const auto block_count = reader.read<std::uint32_t>();
|
||||||
|
collection.collection_payloads.reserve(collection_payload_count);
|
||||||
|
for (std::uint32_t index = 0; index < collection_payload_count; ++index) {
|
||||||
|
collection.collection_payloads.push_back(read_result_payload(reader));
|
||||||
|
}
|
||||||
|
|
||||||
collection.blocks.reserve(block_count);
|
collection.blocks.reserve(block_count);
|
||||||
for (std::uint32_t index = 0; index < block_count; ++index) {
|
for (std::uint32_t index = 0; index < block_count; ++index) {
|
||||||
collection.blocks.push_back(read_result_block(reader));
|
collection.blocks.push_back(read_result_block(reader));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <span>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -29,11 +30,13 @@ class DataProcessor {
|
|||||||
private:
|
private:
|
||||||
[[nodiscard]] auto process_collection(
|
[[nodiscard]] auto process_collection(
|
||||||
const ipc::PreprocessedCollection& preprocessed,
|
const ipc::PreprocessedCollection& preprocessed,
|
||||||
|
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||||
ProcessorInterface& processor,
|
ProcessorInterface& processor,
|
||||||
const ProcessingLiveConfig& live_config
|
const ProcessingLiveConfig& live_config
|
||||||
) -> ipc::ResultCollection;
|
) -> ipc::ResultCollection;
|
||||||
|
|
||||||
[[nodiscard]] auto resolve_processor(const ProcessingLiveConfig& live_config) -> ProcessorInterface&;
|
[[nodiscard]] auto resolve_processor(const ProcessingLiveConfig& live_config) -> ProcessorInterface&;
|
||||||
|
[[nodiscard]] auto should_replay_entire_history(const ProcessingLiveConfig& live_config) const -> bool;
|
||||||
|
|
||||||
const config::RunConfig& config_;
|
const config::RunConfig& config_;
|
||||||
ipc::ShmRing& preprocessed_ring_;
|
ipc::ShmRing& preprocessed_ring_;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace radar::processing {
|
namespace radar::processing {
|
||||||
|
|
||||||
@@ -17,12 +18,24 @@ struct ProcessingLiveConfig {
|
|||||||
std::string processor_mode = "pass_through";
|
std::string processor_mode = "pass_through";
|
||||||
float gain_db = 0.0F;
|
float gain_db = 0.0F;
|
||||||
float phase_deg = 0.0F;
|
float phase_deg = 0.0F;
|
||||||
|
bool pass_through_fixed_y_enabled = false;
|
||||||
|
float pass_through_y_min_db = -100.0F;
|
||||||
|
float pass_through_y_max_db = 0.0F;
|
||||||
std::string bscan_axis = "abs";
|
std::string bscan_axis = "abs";
|
||||||
float bscan_cut_m = 0.824F;
|
float bscan_cut_m = 0.824F;
|
||||||
float bscan_max_depth_m = 1.0F;
|
float bscan_max_depth_m = 1.0F;
|
||||||
float bscan_gain = 1.0F;
|
float bscan_gain = 1.0F;
|
||||||
float bscan_start_freq_mhz = 100.0F;
|
float bscan_start_freq_mhz = 100.0F;
|
||||||
float bscan_stop_freq_mhz = 8800.0F;
|
float bscan_stop_freq_mhz = 8800.0F;
|
||||||
|
std::vector<std::uint32_t> gpr_input_positions{};
|
||||||
|
std::vector<std::uint32_t> gpr_output_positions{};
|
||||||
|
float gpr_min_depth_m = 2.0F;
|
||||||
|
float gpr_max_depth_m = 14.0F;
|
||||||
|
float gpr_comp_power = 0.2F;
|
||||||
|
float gpr_start_freq_mhz = 3000.0F;
|
||||||
|
float gpr_stop_freq_mhz = 6000.0F;
|
||||||
|
bool gpr_background_subtract_enabled = true;
|
||||||
|
std::uint32_t gpr_background_mean_count = 10U;
|
||||||
std::uint64_t history_command_seq = 0;
|
std::uint64_t history_command_seq = 0;
|
||||||
HistoryCommand history_command = HistoryCommand::None;
|
HistoryCommand history_command = HistoryCommand::None;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <deque>
|
#include <span>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "bscan_processor.hpp"
|
#include "bscan_processor.hpp"
|
||||||
|
#include "gpr_processor.hpp"
|
||||||
#include "passthrough_processor.hpp"
|
#include "passthrough_processor.hpp"
|
||||||
|
|
||||||
namespace radar::processing {
|
namespace radar::processing {
|
||||||
@@ -54,7 +55,7 @@ DataProcessor::DataProcessor(
|
|||||||
|
|
||||||
void DataProcessor::run(const std::atomic<bool>& stop_requested) {
|
void DataProcessor::run(const std::atomic<bool>& stop_requested) {
|
||||||
std::vector<std::uint8_t> bytes{};
|
std::vector<std::uint8_t> bytes{};
|
||||||
std::deque<ipc::PreprocessedCollection> preprocessed_history{};
|
std::vector<ipc::PreprocessedCollection> preprocessed_history{};
|
||||||
const std::size_t history_limit = replay_history_limit(config_);
|
const std::size_t history_limit = replay_history_limit(config_);
|
||||||
std::uint64_t last_replayed_revision = live_config_loader_.revision();
|
std::uint64_t last_replayed_revision = live_config_loader_.revision();
|
||||||
std::uint64_t last_applied_history_command_seq = 0;
|
std::uint64_t last_applied_history_command_seq = 0;
|
||||||
@@ -76,13 +77,23 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
|
|||||||
last_applied_history_command_seq = live_config.history_command_seq;
|
last_applied_history_command_seq = live_config.history_command_seq;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (live_config.processor_mode == "bscan") {
|
if (should_replay_entire_history(live_config)) {
|
||||||
for (const auto& cached : preprocessed_history) {
|
for (std::size_t index = 0; index < preprocessed_history.size(); ++index) {
|
||||||
const auto replay_result = process_collection(cached, processor, live_config);
|
const auto replay_result = process_collection(
|
||||||
|
preprocessed_history[index],
|
||||||
|
std::span<const ipc::PreprocessedCollection>(preprocessed_history.data(), index),
|
||||||
|
processor,
|
||||||
|
live_config
|
||||||
|
);
|
||||||
publish_result_collection(replay_result, results_ring_);
|
publish_result_collection(replay_result, results_ring_);
|
||||||
}
|
}
|
||||||
} else if (!preprocessed_history.empty()) {
|
} else if (!preprocessed_history.empty()) {
|
||||||
const auto replay_result = process_collection(preprocessed_history.back(), processor, live_config);
|
const auto replay_result = process_collection(
|
||||||
|
preprocessed_history.back(),
|
||||||
|
std::span<const ipc::PreprocessedCollection>(preprocessed_history.data(), preprocessed_history.size() - 1U),
|
||||||
|
processor,
|
||||||
|
live_config
|
||||||
|
);
|
||||||
publish_result_collection(replay_result, results_ring_);
|
publish_result_collection(replay_result, results_ring_);
|
||||||
}
|
}
|
||||||
last_replayed_revision = live_revision;
|
last_replayed_revision = live_revision;
|
||||||
@@ -92,10 +103,15 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
|
|||||||
auto preprocessed = ipc::deserialize_preprocessed_collection(bytes);
|
auto preprocessed = ipc::deserialize_preprocessed_collection(bytes);
|
||||||
preprocessed_history.push_back(std::move(preprocessed));
|
preprocessed_history.push_back(std::move(preprocessed));
|
||||||
while (preprocessed_history.size() > history_limit) {
|
while (preprocessed_history.size() > history_limit) {
|
||||||
preprocessed_history.pop_front();
|
preprocessed_history.erase(preprocessed_history.begin());
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto result_collection = process_collection(preprocessed_history.back(), processor, live_config);
|
const auto result_collection = process_collection(
|
||||||
|
preprocessed_history.back(),
|
||||||
|
std::span<const ipc::PreprocessedCollection>(preprocessed_history.data(), preprocessed_history.size() - 1U),
|
||||||
|
processor,
|
||||||
|
live_config
|
||||||
|
);
|
||||||
publish_result_collection(result_collection, results_ring_);
|
publish_result_collection(result_collection, results_ring_);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -106,25 +122,11 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
|
|||||||
|
|
||||||
auto DataProcessor::process_collection(
|
auto DataProcessor::process_collection(
|
||||||
const ipc::PreprocessedCollection& preprocessed,
|
const ipc::PreprocessedCollection& preprocessed,
|
||||||
|
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||||
ProcessorInterface& processor,
|
ProcessorInterface& processor,
|
||||||
const ProcessingLiveConfig& live_config
|
const ProcessingLiveConfig& live_config
|
||||||
) -> ipc::ResultCollection {
|
) -> ipc::ResultCollection {
|
||||||
ipc::ResultCollection results{};
|
return processor.process_collection(config_, preprocessed, previous_collections, live_config);
|
||||||
results.collection_id = preprocessed.collection_id;
|
|
||||||
// Keep source monotonic timestamp stable across live-config replays.
|
|
||||||
results.monotonic_ns = preprocessed.monotonic_ns;
|
|
||||||
results.blocks.reserve(preprocessed.traces.size());
|
|
||||||
|
|
||||||
for (const auto& trace : preprocessed.traces) {
|
|
||||||
ipc::ResultBlock block{};
|
|
||||||
block.combo = trace.combo;
|
|
||||||
block.payloads.reserve(1U);
|
|
||||||
block.payloads.push_back(processor.process(trace, live_config));
|
|
||||||
|
|
||||||
results.blocks.push_back(std::move(block));
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
auto DataProcessor::resolve_processor(const ProcessingLiveConfig& live_config) -> ProcessorInterface& {
|
auto DataProcessor::resolve_processor(const ProcessingLiveConfig& live_config) -> ProcessorInterface& {
|
||||||
@@ -140,6 +142,12 @@ auto DataProcessor::resolve_processor(const ProcessingLiveConfig& live_config) -
|
|||||||
return *(processors_.begin()->second);
|
return *(processors_.begin()->second);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto DataProcessor::should_replay_entire_history(const ProcessingLiveConfig& live_config) const -> bool {
|
||||||
|
const std::string requested_mode =
|
||||||
|
live_config.processor_mode.empty() ? default_processor_mode_ : live_config.processor_mode;
|
||||||
|
return requested_mode == "bscan";
|
||||||
|
}
|
||||||
|
|
||||||
auto create_default_processors() -> ProcessorRegistry {
|
auto create_default_processors() -> ProcessorRegistry {
|
||||||
ProcessorRegistry processors{};
|
ProcessorRegistry processors{};
|
||||||
{
|
{
|
||||||
@@ -150,6 +158,10 @@ auto create_default_processors() -> ProcessorRegistry {
|
|||||||
auto processor = std::make_unique<BScanProcessor>();
|
auto processor = std::make_unique<BScanProcessor>();
|
||||||
processors.emplace(processor->name(), std::move(processor));
|
processors.emplace(processor->name(), std::move(processor));
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
auto processor = std::make_unique<GprProcessor>();
|
||||||
|
processors.emplace(processor->name(), std::move(processor));
|
||||||
|
}
|
||||||
return processors;
|
return processors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,27 @@ using Json = nlohmann::json;
|
|||||||
return static_cast<std::uint64_t>(rounded);
|
return static_cast<std::uint64_t>(rounded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto parse_u32_number(const Json& value, const std::string& field_name) -> std::uint32_t {
|
||||||
|
const auto parsed = parse_u64_number(value, field_name);
|
||||||
|
if (parsed > static_cast<std::uint64_t>(std::numeric_limits<std::uint32_t>::max())) {
|
||||||
|
throw std::runtime_error(field_name + " is out of uint32 range");
|
||||||
|
}
|
||||||
|
return static_cast<std::uint32_t>(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto parse_u32_array(const Json& value, const std::string& field_name) -> std::vector<std::uint32_t> {
|
||||||
|
if (!value.is_array()) {
|
||||||
|
throw std::runtime_error(field_name + " must be array");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::uint32_t> result{};
|
||||||
|
result.reserve(value.size());
|
||||||
|
for (std::size_t index = 0; index < value.size(); ++index) {
|
||||||
|
result.push_back(parse_u32_number(value[index], field_name + "[" + std::to_string(index) + "]"));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
[[nodiscard]] auto parse_live_config(const std::string& json_text, const std::string& path) -> ProcessingLiveConfig {
|
[[nodiscard]] auto parse_live_config(const std::string& json_text, const std::string& path) -> ProcessingLiveConfig {
|
||||||
if (json_text.empty()) {
|
if (json_text.empty()) {
|
||||||
throw std::runtime_error("Processing live config is empty: " + path);
|
throw std::runtime_error("Processing live config is empty: " + path);
|
||||||
@@ -80,6 +101,24 @@ using Json = nlohmann::json;
|
|||||||
}
|
}
|
||||||
config.phase_deg = static_cast<float>(found->get<double>());
|
config.phase_deg = static_cast<float>(found->get<double>());
|
||||||
}
|
}
|
||||||
|
if (const auto found = root.find("pass_through_fixed_y_enabled"); found != root.end()) {
|
||||||
|
if (!found->is_boolean()) {
|
||||||
|
throw std::runtime_error("processing.pass_through_fixed_y_enabled must be bool");
|
||||||
|
}
|
||||||
|
config.pass_through_fixed_y_enabled = found->get<bool>();
|
||||||
|
}
|
||||||
|
if (const auto found = root.find("pass_through_y_min_db"); found != root.end()) {
|
||||||
|
if (!found->is_number()) {
|
||||||
|
throw std::runtime_error("processing.pass_through_y_min_db must be number");
|
||||||
|
}
|
||||||
|
config.pass_through_y_min_db = static_cast<float>(found->get<double>());
|
||||||
|
}
|
||||||
|
if (const auto found = root.find("pass_through_y_max_db"); found != root.end()) {
|
||||||
|
if (!found->is_number()) {
|
||||||
|
throw std::runtime_error("processing.pass_through_y_max_db must be number");
|
||||||
|
}
|
||||||
|
config.pass_through_y_max_db = static_cast<float>(found->get<double>());
|
||||||
|
}
|
||||||
if (const auto found = root.find("bscan_axis"); found != root.end()) {
|
if (const auto found = root.find("bscan_axis"); found != root.end()) {
|
||||||
if (!found->is_string()) {
|
if (!found->is_string()) {
|
||||||
throw std::runtime_error("processing.bscan_axis must be string");
|
throw std::runtime_error("processing.bscan_axis must be string");
|
||||||
@@ -116,6 +155,51 @@ using Json = nlohmann::json;
|
|||||||
}
|
}
|
||||||
config.bscan_stop_freq_mhz = static_cast<float>(found->get<double>());
|
config.bscan_stop_freq_mhz = static_cast<float>(found->get<double>());
|
||||||
}
|
}
|
||||||
|
if (const auto found = root.find("gpr_input_positions"); found != root.end()) {
|
||||||
|
config.gpr_input_positions = parse_u32_array(*found, "processing.gpr_input_positions");
|
||||||
|
}
|
||||||
|
if (const auto found = root.find("gpr_output_positions"); found != root.end()) {
|
||||||
|
config.gpr_output_positions = parse_u32_array(*found, "processing.gpr_output_positions");
|
||||||
|
}
|
||||||
|
if (const auto found = root.find("gpr_min_depth_m"); found != root.end()) {
|
||||||
|
if (!found->is_number()) {
|
||||||
|
throw std::runtime_error("processing.gpr_min_depth_m must be number");
|
||||||
|
}
|
||||||
|
config.gpr_min_depth_m = static_cast<float>(found->get<double>());
|
||||||
|
}
|
||||||
|
if (const auto found = root.find("gpr_max_depth_m"); found != root.end()) {
|
||||||
|
if (!found->is_number()) {
|
||||||
|
throw std::runtime_error("processing.gpr_max_depth_m must be number");
|
||||||
|
}
|
||||||
|
config.gpr_max_depth_m = static_cast<float>(found->get<double>());
|
||||||
|
}
|
||||||
|
if (const auto found = root.find("gpr_comp_power"); found != root.end()) {
|
||||||
|
if (!found->is_number()) {
|
||||||
|
throw std::runtime_error("processing.gpr_comp_power must be number");
|
||||||
|
}
|
||||||
|
config.gpr_comp_power = static_cast<float>(found->get<double>());
|
||||||
|
}
|
||||||
|
if (const auto found = root.find("gpr_start_freq_mhz"); found != root.end()) {
|
||||||
|
if (!found->is_number()) {
|
||||||
|
throw std::runtime_error("processing.gpr_start_freq_mhz must be number");
|
||||||
|
}
|
||||||
|
config.gpr_start_freq_mhz = static_cast<float>(found->get<double>());
|
||||||
|
}
|
||||||
|
if (const auto found = root.find("gpr_stop_freq_mhz"); found != root.end()) {
|
||||||
|
if (!found->is_number()) {
|
||||||
|
throw std::runtime_error("processing.gpr_stop_freq_mhz must be number");
|
||||||
|
}
|
||||||
|
config.gpr_stop_freq_mhz = static_cast<float>(found->get<double>());
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
config.gpr_background_subtract_enabled = found->get<bool>();
|
||||||
|
}
|
||||||
|
if (const auto found = root.find("gpr_background_mean_count"); found != root.end()) {
|
||||||
|
config.gpr_background_mean_count = parse_u32_number(*found, "processing.gpr_background_mean_count");
|
||||||
|
}
|
||||||
if (const auto found = root.find("history_command_seq"); found != root.end()) {
|
if (const auto found = root.find("history_command_seq"); found != root.end()) {
|
||||||
config.history_command_seq = parse_u64_number(*found, "processing.history_command_seq");
|
config.history_command_seq = parse_u64_number(*found, "processing.history_command_seq");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ namespace radar::processing {
|
|||||||
class BScanProcessor final : public ProcessorInterface {
|
class BScanProcessor final : public ProcessorInterface {
|
||||||
public:
|
public:
|
||||||
[[nodiscard]] auto name() const -> std::string override;
|
[[nodiscard]] auto name() const -> std::string override;
|
||||||
[[nodiscard]] auto process(
|
[[nodiscard]] auto process_collection(
|
||||||
const ipc::SweepTraceBlock& trace,
|
const config::RunConfig& run_config,
|
||||||
|
const ipc::PreprocessedCollection& collection,
|
||||||
|
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||||
const ProcessingLiveConfig& live_config
|
const ProcessingLiveConfig& live_config
|
||||||
) -> ipc::ResultPayload override;
|
) -> ipc::ResultCollection override;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace radar::processing
|
} // namespace radar::processing
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "processor_interface.hpp"
|
||||||
|
|
||||||
|
namespace radar::processing {
|
||||||
|
|
||||||
|
class GprProcessor final : public ProcessorInterface {
|
||||||
|
public:
|
||||||
|
[[nodiscard]] auto name() const -> std::string override;
|
||||||
|
[[nodiscard]] auto process_collection(
|
||||||
|
const config::RunConfig& run_config,
|
||||||
|
const ipc::PreprocessedCollection& collection,
|
||||||
|
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||||
|
const ProcessingLiveConfig& live_config
|
||||||
|
) -> ipc::ResultCollection override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace radar::processing
|
||||||
@@ -7,10 +7,12 @@ namespace radar::processing {
|
|||||||
class PassThroughProcessor final : public ProcessorInterface {
|
class PassThroughProcessor final : public ProcessorInterface {
|
||||||
public:
|
public:
|
||||||
[[nodiscard]] auto name() const -> std::string override;
|
[[nodiscard]] auto name() const -> std::string override;
|
||||||
[[nodiscard]] auto process(
|
[[nodiscard]] auto process_collection(
|
||||||
const ipc::SweepTraceBlock& trace,
|
const config::RunConfig& run_config,
|
||||||
|
const ipc::PreprocessedCollection& collection,
|
||||||
|
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||||
const ProcessingLiveConfig& live_config
|
const ProcessingLiveConfig& live_config
|
||||||
) -> ipc::ResultPayload override;
|
) -> ipc::ResultCollection override;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace radar::processing
|
} // namespace radar::processing
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <span>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "processing_live_config.hpp"
|
#include "processing_live_config.hpp"
|
||||||
|
#include "run_config.hpp"
|
||||||
#include "shared_types.hpp"
|
#include "shared_types.hpp"
|
||||||
|
|
||||||
namespace radar::processing {
|
namespace radar::processing {
|
||||||
@@ -12,10 +14,12 @@ class ProcessorInterface {
|
|||||||
virtual ~ProcessorInterface() = default;
|
virtual ~ProcessorInterface() = default;
|
||||||
|
|
||||||
[[nodiscard]] virtual auto name() const -> std::string = 0;
|
[[nodiscard]] virtual auto name() const -> std::string = 0;
|
||||||
[[nodiscard]] virtual auto process(
|
[[nodiscard]] virtual auto process_collection(
|
||||||
const ipc::SweepTraceBlock& trace,
|
const config::RunConfig& run_config,
|
||||||
|
const ipc::PreprocessedCollection& collection,
|
||||||
|
std::span<const ipc::PreprocessedCollection> previous_collections,
|
||||||
const ProcessingLiveConfig& live_config
|
const ProcessingLiveConfig& live_config
|
||||||
) -> ipc::ResultPayload = 0;
|
) -> ipc::ResultCollection = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace radar::processing
|
} // namespace radar::processing
|
||||||
|
|||||||
@@ -215,23 +215,39 @@ auto BScanProcessor::name() const -> std::string {
|
|||||||
return "bscan";
|
return "bscan";
|
||||||
}
|
}
|
||||||
|
|
||||||
auto BScanProcessor::process(const ipc::SweepTraceBlock& trace, const ProcessingLiveConfig& live_config)
|
auto BScanProcessor::process_collection(
|
||||||
-> ipc::ResultPayload {
|
const config::RunConfig& /*run_config*/,
|
||||||
ipc::ResultPayload payload{};
|
const ipc::PreprocessedCollection& collection,
|
||||||
payload.processing_name = name();
|
std::span<const ipc::PreprocessedCollection> /*previous_collections*/,
|
||||||
payload.kind = ipc::ResultKind::TraceComplex;
|
const ProcessingLiveConfig& live_config
|
||||||
|
) -> ipc::ResultCollection {
|
||||||
|
ipc::ResultCollection results{};
|
||||||
|
results.collection_id = collection.collection_id;
|
||||||
|
results.monotonic_ns = collection.monotonic_ns;
|
||||||
|
results.blocks.reserve(collection.traces.size());
|
||||||
|
|
||||||
auto profile = compute_bscan_profile(trace, live_config);
|
for (const auto& trace : collection.traces) {
|
||||||
payload.frequency_hz = std::move(profile.depth_m);
|
ipc::ResultPayload payload{};
|
||||||
payload.trace.reserve(profile.response.size());
|
payload.processing_name = name();
|
||||||
for (const auto value : profile.response) {
|
payload.kind = ipc::ResultKind::TraceComplex;
|
||||||
payload.trace.push_back(ipc::Complex32{
|
|
||||||
.re = value,
|
auto profile = compute_bscan_profile(trace, live_config);
|
||||||
.im = 0.0F,
|
payload.frequency_hz = std::move(profile.depth_m);
|
||||||
});
|
payload.trace.reserve(profile.response.size());
|
||||||
|
for (const auto value : profile.response) {
|
||||||
|
payload.trace.push_back(ipc::Complex32{
|
||||||
|
.re = value,
|
||||||
|
.im = 0.0F,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ipc::ResultBlock block{};
|
||||||
|
block.combo = trace.combo;
|
||||||
|
block.payloads.push_back(std::move(payload));
|
||||||
|
results.blocks.push_back(std::move(block));
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace radar::processing
|
} // namespace radar::processing
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,27 +13,43 @@ auto PassThroughProcessor::name() const -> std::string {
|
|||||||
return "pass_through";
|
return "pass_through";
|
||||||
}
|
}
|
||||||
|
|
||||||
auto PassThroughProcessor::process(const ipc::SweepTraceBlock& trace, const ProcessingLiveConfig& live_config)
|
auto PassThroughProcessor::process_collection(
|
||||||
-> ipc::ResultPayload {
|
const config::RunConfig& /*run_config*/,
|
||||||
ipc::ResultPayload payload{};
|
const ipc::PreprocessedCollection& collection,
|
||||||
payload.processing_name = name();
|
std::span<const ipc::PreprocessedCollection> /*previous_collections*/,
|
||||||
payload.kind = ipc::ResultKind::TraceComplex;
|
const ProcessingLiveConfig& live_config
|
||||||
payload.frequency_hz = trace.frequency_hz;
|
) -> ipc::ResultCollection {
|
||||||
payload.trace = trace.s21;
|
ipc::ResultCollection results{};
|
||||||
|
results.collection_id = collection.collection_id;
|
||||||
|
results.monotonic_ns = collection.monotonic_ns;
|
||||||
|
results.blocks.reserve(collection.traces.size());
|
||||||
|
|
||||||
const float linear_gain = std::pow(10.0F, live_config.gain_db / 20.0F);
|
for (const auto& trace : collection.traces) {
|
||||||
const float phase_rad = live_config.phase_deg * (kPi / 180.0F);
|
ipc::ResultPayload payload{};
|
||||||
const float cos_phase = std::cos(phase_rad);
|
payload.processing_name = name();
|
||||||
const float sin_phase = std::sin(phase_rad);
|
payload.kind = ipc::ResultKind::TraceComplex;
|
||||||
|
payload.frequency_hz = trace.frequency_hz;
|
||||||
|
payload.trace = trace.s21;
|
||||||
|
|
||||||
for (auto& sample : payload.trace) {
|
const float linear_gain = std::pow(10.0F, live_config.gain_db / 20.0F);
|
||||||
const float re = sample.re;
|
const float phase_rad = live_config.phase_deg * (kPi / 180.0F);
|
||||||
const float im = sample.im;
|
const float cos_phase = std::cos(phase_rad);
|
||||||
sample.re = linear_gain * ((re * cos_phase) - (im * sin_phase));
|
const float sin_phase = std::sin(phase_rad);
|
||||||
sample.im = linear_gain * ((re * sin_phase) + (im * cos_phase));
|
|
||||||
|
for (auto& sample : payload.trace) {
|
||||||
|
const float re = sample.re;
|
||||||
|
const float im = sample.im;
|
||||||
|
sample.re = linear_gain * ((re * cos_phase) - (im * sin_phase));
|
||||||
|
sample.im = linear_gain * ((re * sin_phase) + (im * cos_phase));
|
||||||
|
}
|
||||||
|
|
||||||
|
ipc::ResultBlock block{};
|
||||||
|
block.combo = trace.combo;
|
||||||
|
block.payloads.push_back(std::move(payload));
|
||||||
|
results.blocks.push_back(std::move(block));
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace radar::processing
|
} // namespace radar::processing
|
||||||
|
|||||||
@@ -104,6 +104,18 @@ class AppWindow(
|
|||||||
self._bscan_depth_axis_by_combo = {}
|
self._bscan_depth_axis_by_combo = {}
|
||||||
self._bscan_history_floor_collection_id = 0
|
self._bscan_history_floor_collection_id = 0
|
||||||
self._bscan_render_signature = None
|
self._bscan_render_signature = None
|
||||||
|
self._gpr_lookup_table = None
|
||||||
|
self._gpr_image_item = None
|
||||||
|
self._gpr_tx_item = None
|
||||||
|
self._gpr_rx_item = None
|
||||||
|
self._gpr_points_item = None
|
||||||
|
self._gpr_region_centers_item = None
|
||||||
|
self._gpr_point_labels = []
|
||||||
|
self._gpr_region_center_labels = []
|
||||||
|
self._gpr_region_mask_items = []
|
||||||
|
self._gpr_region_contours = []
|
||||||
|
self._gpr_geometry_signature = None
|
||||||
|
self._gpr_selected_geometry = None
|
||||||
self._phase_viewbox = None
|
self._phase_viewbox = None
|
||||||
self._history_run_signature = None
|
self._history_run_signature = None
|
||||||
self._radar_limits: dict[str, float | int] | None = None
|
self._radar_limits: dict[str, float | int] | None = None
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from python_app.gui.runtime.history import remove_last_aligned_histories
|
from python_app.gui.runtime.history import remove_last_aligned_histories
|
||||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||||
from python_app.models.run_config_model import ComboModel, RunConfigModel
|
from python_app.models.run_config_model import ComboModel, GprRxGeometryModel, GprTxGeometryModel, RunConfigModel
|
||||||
|
from python_app.models.run_config_validation import validate_gpr_model
|
||||||
from python_app.orchestration.config_writer import parse_combos_from_text
|
from python_app.orchestration.config_writer import parse_combos_from_text
|
||||||
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||||
from python_app.storage.npz_store import radar_key_from_config
|
from python_app.storage.npz_store import radar_key_from_config
|
||||||
@@ -13,6 +14,58 @@ from python_app.storage.npz_store import radar_key_from_config
|
|||||||
class AppWindowConfigMixin:
|
class AppWindowConfigMixin:
|
||||||
"""Builds runtime config models from current UI state."""
|
"""Builds runtime config models from current UI state."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_csv_int_list(text: str) -> list[int]:
|
||||||
|
"""Parse comma-separated integer selection list."""
|
||||||
|
cleaned = text.strip()
|
||||||
|
if not cleaned:
|
||||||
|
return []
|
||||||
|
values: list[int] = []
|
||||||
|
for part in cleaned.split(","):
|
||||||
|
token = part.strip()
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
values.append(int(token))
|
||||||
|
return values
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_gpr_tx_geometry_text(text: str) -> list[GprTxGeometryModel]:
|
||||||
|
"""Parse line-based Tx geometry editor text."""
|
||||||
|
entries: list[GprTxGeometryModel] = []
|
||||||
|
for line_number, raw_line in enumerate(text.splitlines(), start=1):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) != 2:
|
||||||
|
raise ValueError(f"Invalid Tx geometry line {line_number}: expected `output_pos x_m`")
|
||||||
|
entries.append(
|
||||||
|
GprTxGeometryModel(
|
||||||
|
output_pos=int(parts[0]),
|
||||||
|
x_m=float(parts[1]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_gpr_rx_geometry_text(text: str) -> list[GprRxGeometryModel]:
|
||||||
|
"""Parse line-based Rx geometry editor text."""
|
||||||
|
entries: list[GprRxGeometryModel] = []
|
||||||
|
for line_number, raw_line in enumerate(text.splitlines(), start=1):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) != 2:
|
||||||
|
raise ValueError(f"Invalid Rx geometry line {line_number}: expected `input_pos x_m`")
|
||||||
|
entries.append(
|
||||||
|
GprRxGeometryModel(
|
||||||
|
input_pos=int(parts[0]),
|
||||||
|
x_m=float(parts[1]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
def _save_current_config(self) -> None:
|
def _save_current_config(self) -> None:
|
||||||
"""Persist currently selected GUI settings into root run_config.json."""
|
"""Persist currently selected GUI settings into root run_config.json."""
|
||||||
try:
|
try:
|
||||||
@@ -68,6 +121,15 @@ class AppWindowConfigMixin:
|
|||||||
|
|
||||||
config.preprocess.calibration_set = self._selected_calibration_set
|
config.preprocess.calibration_set = self._selected_calibration_set
|
||||||
config.preprocess.reference_set = self._selected_reference_set
|
config.preprocess.reference_set = self._selected_reference_set
|
||||||
|
config.gpr.mode = self._gpr_config_mode.currentText()
|
||||||
|
config.gpr.relative_permittivity = float(self._gpr_relative_permittivity.value())
|
||||||
|
config.gpr.tx_geometry = self._parse_gpr_tx_geometry_text(self._gpr_tx_geometry_input.toPlainText())
|
||||||
|
config.gpr.rx_geometry = self._parse_gpr_rx_geometry_text(self._gpr_rx_geometry_input.toPlainText())
|
||||||
|
validate_gpr_model(
|
||||||
|
config.gpr,
|
||||||
|
input_switch_positions=config.input_switch.positions,
|
||||||
|
output_switch_positions=config.output_switch.positions,
|
||||||
|
)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
def _radar_key(self, config: RunConfigModel) -> str:
|
def _radar_key(self, config: RunConfigModel) -> str:
|
||||||
@@ -85,16 +147,31 @@ class AppWindowConfigMixin:
|
|||||||
def _live_processing_config(self, *, history_command: str = "none") -> ProcessingLiveConfig:
|
def _live_processing_config(self, *, history_command: str = "none") -> ProcessingLiveConfig:
|
||||||
"""Build live processing config from current processing widgets."""
|
"""Build live processing config from current processing widgets."""
|
||||||
self._sync_bscan_frequency_limits_with_radar()
|
self._sync_bscan_frequency_limits_with_radar()
|
||||||
|
self._sync_gpr_frequency_limits_with_radar()
|
||||||
|
y_min_db = float(self._pass_through_y_min_db.value())
|
||||||
|
y_max_db = float(self._pass_through_y_max_db.value())
|
||||||
return ProcessingLiveConfig(
|
return ProcessingLiveConfig(
|
||||||
processor_mode=self._processing_mode.currentText(),
|
processor_mode=self._processing_mode.currentText(),
|
||||||
gain_db=float(self._processing_gain_db.value()),
|
gain_db=float(self._processing_gain_db.value()),
|
||||||
phase_deg=float(self._processing_phase_deg.value()),
|
phase_deg=float(self._processing_phase_deg.value()),
|
||||||
|
pass_through_fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()),
|
||||||
|
pass_through_y_min_db=min(y_min_db, y_max_db),
|
||||||
|
pass_through_y_max_db=max(y_min_db, y_max_db),
|
||||||
bscan_axis=self._bscan_axis.currentText(),
|
bscan_axis=self._bscan_axis.currentText(),
|
||||||
bscan_cut_m=float(self._bscan_cut_m.value()),
|
bscan_cut_m=float(self._bscan_cut_m.value()),
|
||||||
bscan_max_depth_m=float(self._bscan_max_depth_m.value()),
|
bscan_max_depth_m=float(self._bscan_max_depth_m.value()),
|
||||||
bscan_gain=float(self._bscan_gain.value()),
|
bscan_gain=float(self._bscan_gain.value()),
|
||||||
bscan_start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
|
bscan_start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
|
||||||
bscan_stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
|
bscan_stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
|
||||||
|
gpr_input_positions=self._parse_csv_int_list(self._gpr_input_positions_input.text()),
|
||||||
|
gpr_output_positions=self._parse_csv_int_list(self._gpr_output_positions_input.text()),
|
||||||
|
gpr_min_depth_m=float(self._gpr_min_depth_m.value()),
|
||||||
|
gpr_max_depth_m=float(self._gpr_max_depth_m.value()),
|
||||||
|
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_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),
|
history_command_seq=int(self._history_command_seq),
|
||||||
history_command=str(history_command),
|
history_command=str(history_command),
|
||||||
)
|
)
|
||||||
@@ -109,10 +186,18 @@ class AppWindowConfigMixin:
|
|||||||
"""Handle live-processing setting changes and trigger redraw when needed."""
|
"""Handle live-processing setting changes and trigger redraw when needed."""
|
||||||
try:
|
try:
|
||||||
self._write_live_processing_config()
|
self._write_live_processing_config()
|
||||||
if self._processing_mode.currentText() == "bscan":
|
current_mode = self._processing_mode.currentText()
|
||||||
|
if current_mode == "bscan":
|
||||||
self._drain_results_until_quiet(timeout_s=0.25, poll_s=0.01)
|
self._drain_results_until_quiet(timeout_s=0.25, poll_s=0.01)
|
||||||
self._sync_bscan_history_from_results()
|
self._sync_bscan_history_from_results()
|
||||||
self._draw_bscan_heatmap_from_history()
|
self._draw_bscan_heatmap_from_history()
|
||||||
|
elif current_mode == "gpr":
|
||||||
|
self._drain_results_until_quiet(timeout_s=0.35, poll_s=0.01)
|
||||||
|
if self._result_history:
|
||||||
|
if not self._draw_results(self._result_history[-1]):
|
||||||
|
self._clear_gpr_plot()
|
||||||
|
else:
|
||||||
|
self._clear_gpr_plot()
|
||||||
elif self._result_history:
|
elif self._result_history:
|
||||||
self._draw_results(self._result_history[-1])
|
self._draw_results(self._result_history[-1])
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
@@ -123,6 +208,7 @@ class AppWindowConfigMixin:
|
|||||||
mode_to_page = {
|
mode_to_page = {
|
||||||
"pass_through": 0,
|
"pass_through": 0,
|
||||||
"bscan": 1,
|
"bscan": 1,
|
||||||
|
"gpr": 2,
|
||||||
}
|
}
|
||||||
self._set_plot_mode(mode)
|
self._set_plot_mode(mode)
|
||||||
self._processing_mode_pages.setCurrentIndex(mode_to_page.get(mode, 0))
|
self._processing_mode_pages.setCurrentIndex(mode_to_page.get(mode, 0))
|
||||||
@@ -134,17 +220,31 @@ class AppWindowConfigMixin:
|
|||||||
|
|
||||||
def _on_bscan_clear_history_clicked(self) -> None:
|
def _on_bscan_clear_history_clicked(self) -> None:
|
||||||
"""Permanently clear all runtime histories, ring backlogs, and B-scan cache."""
|
"""Permanently clear all runtime histories, ring backlogs, and B-scan cache."""
|
||||||
self._apply_bscan_history_deletion(remove_last_only=False)
|
self._apply_history_mode_deletion(mode_label="B-scan", remove_last_only=False)
|
||||||
|
|
||||||
def _on_bscan_remove_last_sweep_clicked(self) -> None:
|
def _on_bscan_remove_last_sweep_clicked(self) -> None:
|
||||||
"""Permanently delete the latest sweep from runtime histories and rings."""
|
"""Permanently delete the latest sweep from runtime histories and rings."""
|
||||||
self._apply_bscan_history_deletion(remove_last_only=True)
|
self._apply_history_mode_deletion(mode_label="B-scan", remove_last_only=True)
|
||||||
|
|
||||||
def _apply_bscan_history_deletion(self, *, remove_last_only: bool) -> None:
|
def _on_gpr_clear_history_clicked(self) -> None:
|
||||||
"""Apply destructive B-scan history deletion via C++ processor history commands."""
|
"""Permanently clear all runtime histories, ring backlogs, and GPR cache."""
|
||||||
|
self._apply_history_mode_deletion(mode_label="GPR", remove_last_only=False)
|
||||||
|
|
||||||
|
def _on_gpr_remove_last_measurement_clicked(self) -> None:
|
||||||
|
"""Permanently delete the latest measurement from runtime histories and rings."""
|
||||||
|
self._apply_history_mode_deletion(mode_label="GPR", remove_last_only=True)
|
||||||
|
|
||||||
|
def _clear_history_mode_caches(self) -> None:
|
||||||
|
"""Drop mode-specific cached render state."""
|
||||||
|
self._clear_bscan_plot_history()
|
||||||
|
if hasattr(self, "_gpr_plot"):
|
||||||
|
self._clear_gpr_plot()
|
||||||
|
|
||||||
|
def _apply_history_mode_deletion(self, *, mode_label: str, remove_last_only: bool) -> None:
|
||||||
|
"""Apply destructive history deletion via C++ processor history commands."""
|
||||||
resume_acquisition = self._supervisor.is_running()
|
resume_acquisition = self._supervisor.is_running()
|
||||||
history_command = "remove_last" if remove_last_only else "clear_all"
|
history_command = "remove_last" if remove_last_only else "clear_all"
|
||||||
action = "last sweep removed" if remove_last_only else "history fully cleared"
|
action = "last measurement removed" if remove_last_only else "history fully cleared"
|
||||||
dropped_results = 0
|
dropped_results = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -169,7 +269,7 @@ class AppWindowConfigMixin:
|
|||||||
retained_pre=retained_pre,
|
retained_pre=retained_pre,
|
||||||
retained_result=retained_result,
|
retained_result=retained_result,
|
||||||
)
|
)
|
||||||
self._clear_bscan_plot_history()
|
self._clear_history_mode_caches()
|
||||||
|
|
||||||
self._write_live_processing_config(history_command=history_command, bump_history_seq=True)
|
self._write_live_processing_config(history_command=history_command, bump_history_seq=True)
|
||||||
|
|
||||||
@@ -180,9 +280,9 @@ class AppWindowConfigMixin:
|
|||||||
self._redraw_after_history_deletion()
|
self._redraw_after_history_deletion()
|
||||||
if resume_acquisition:
|
if resume_acquisition:
|
||||||
self._start_run()
|
self._start_run()
|
||||||
self._log(f"B-scan {action}; dropped pending results={dropped_results}")
|
self._log(f"{mode_label} {action}; dropped pending results={dropped_results}")
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self._show_error(f"Failed to delete B-scan history: {exc}")
|
self._show_error(f"Failed to delete {mode_label} history: {exc}")
|
||||||
|
|
||||||
def _redraw_after_history_deletion(self) -> None:
|
def _redraw_after_history_deletion(self) -> None:
|
||||||
"""Refresh plot immediately after destructive history deletion."""
|
"""Refresh plot immediately after destructive history deletion."""
|
||||||
@@ -192,6 +292,11 @@ class AppWindowConfigMixin:
|
|||||||
if not self._draw_bscan_heatmap_from_history():
|
if not self._draw_bscan_heatmap_from_history():
|
||||||
self._bscan_plot.clear()
|
self._bscan_plot.clear()
|
||||||
return
|
return
|
||||||
|
if self._processing_mode.currentText() == "gpr":
|
||||||
|
if self._result_history and self._draw_results(self._result_history[-1]):
|
||||||
|
return
|
||||||
|
self._clear_gpr_plot()
|
||||||
|
return
|
||||||
if self._result_history:
|
if self._result_history:
|
||||||
self._draw_results(self._result_history[-1])
|
self._draw_results(self._result_history[-1])
|
||||||
return
|
return
|
||||||
@@ -208,8 +313,8 @@ class AppWindowConfigMixin:
|
|||||||
self._on_processing_live_settings_changed()
|
self._on_processing_live_settings_changed()
|
||||||
|
|
||||||
def _on_radar_sweep_limits_changed(self) -> None:
|
def _on_radar_sweep_limits_changed(self) -> None:
|
||||||
"""Clamp B-scan frequency bounds after sweep start/stop edits."""
|
"""Clamp processing frequency bounds after sweep start/stop edits."""
|
||||||
if self._sync_bscan_frequency_limits_with_radar():
|
if self._sync_processing_frequency_limits_with_radar():
|
||||||
self._on_processing_live_settings_changed()
|
self._on_processing_live_settings_changed()
|
||||||
|
|
||||||
def _refresh_radar_limits_from_device(self) -> bool:
|
def _refresh_radar_limits_from_device(self) -> bool:
|
||||||
@@ -301,7 +406,7 @@ class AppWindowConfigMixin:
|
|||||||
or prev_power != self._power_input.text().strip()
|
or prev_power != self._power_input.text().strip()
|
||||||
)
|
)
|
||||||
|
|
||||||
self._sync_bscan_frequency_limits_with_radar()
|
self._sync_processing_frequency_limits_with_radar()
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -326,16 +431,16 @@ class AppWindowConfigMixin:
|
|||||||
widget.setText(str(value))
|
widget.setText(str(value))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def _sync_bscan_frequency_limits_with_radar(self) -> bool:
|
def _sync_processing_frequency_limits_with_radar(self) -> bool:
|
||||||
"""Synchronize B-scan start/stop MHz widget ranges with radar sweep bounds."""
|
"""Synchronize all processing frequency widgets with radar sweep bounds."""
|
||||||
required_widgets = (
|
bscan_changed = self._sync_bscan_frequency_limits_with_radar()
|
||||||
"_start_hz_input",
|
gpr_changed = self._sync_gpr_frequency_limits_with_radar()
|
||||||
"_stop_hz_input",
|
return bscan_changed or gpr_changed
|
||||||
"_bscan_start_freq_mhz",
|
|
||||||
"_bscan_stop_freq_mhz",
|
def _sync_frequency_spinboxes_with_radar(self, widget_names: tuple[str, ...]) -> bool:
|
||||||
)
|
"""Synchronize one or more MHz spin boxes with current radar sweep bounds."""
|
||||||
|
required_widgets = ("_start_hz_input", "_stop_hz_input", *widget_names)
|
||||||
if not all(hasattr(self, widget_name) for widget_name in required_widgets):
|
if not all(hasattr(self, widget_name) for widget_name in required_widgets):
|
||||||
# Processing callbacks can fire while UI groups are still being built.
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -348,28 +453,41 @@ class AppWindowConfigMixin:
|
|||||||
radar_max_mhz = max(radar_start_hz, radar_stop_hz) / 1_000_000.0
|
radar_max_mhz = max(radar_start_hz, radar_stop_hz) / 1_000_000.0
|
||||||
|
|
||||||
changed = False
|
changed = False
|
||||||
for widget in (self._bscan_start_freq_mhz, self._bscan_stop_freq_mhz):
|
widgets = [getattr(self, widget_name) for widget_name in widget_names]
|
||||||
|
for widget in widgets:
|
||||||
if widget.minimum() != radar_min_mhz or widget.maximum() != radar_max_mhz:
|
if widget.minimum() != radar_min_mhz or widget.maximum() != radar_max_mhz:
|
||||||
changed = True
|
changed = True
|
||||||
widget.blockSignals(True)
|
widget.blockSignals(True)
|
||||||
widget.setRange(radar_min_mhz, radar_max_mhz)
|
widget.setRange(radar_min_mhz, radar_max_mhz)
|
||||||
widget.blockSignals(False)
|
widget.blockSignals(False)
|
||||||
|
|
||||||
clamped_start_mhz = min(max(self._bscan_start_freq_mhz.value(), radar_min_mhz), radar_max_mhz)
|
for widget in widgets:
|
||||||
clamped_stop_mhz = min(max(self._bscan_stop_freq_mhz.value(), radar_min_mhz), radar_max_mhz)
|
clamped_value = min(max(widget.value(), radar_min_mhz), radar_max_mhz)
|
||||||
if clamped_start_mhz != self._bscan_start_freq_mhz.value():
|
if clamped_value != widget.value():
|
||||||
changed = True
|
changed = True
|
||||||
self._bscan_start_freq_mhz.blockSignals(True)
|
widget.blockSignals(True)
|
||||||
self._bscan_start_freq_mhz.setValue(clamped_start_mhz)
|
widget.setValue(clamped_value)
|
||||||
self._bscan_start_freq_mhz.blockSignals(False)
|
widget.blockSignals(False)
|
||||||
if clamped_stop_mhz != self._bscan_stop_freq_mhz.value():
|
|
||||||
changed = True
|
|
||||||
self._bscan_stop_freq_mhz.blockSignals(True)
|
|
||||||
self._bscan_stop_freq_mhz.setValue(clamped_stop_mhz)
|
|
||||||
self._bscan_stop_freq_mhz.blockSignals(False)
|
|
||||||
|
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
def _sync_bscan_frequency_limits_with_radar(self) -> bool:
|
||||||
|
"""Synchronize B-scan start/stop MHz widget ranges with radar sweep bounds."""
|
||||||
|
return self._sync_frequency_spinboxes_with_radar(
|
||||||
|
(
|
||||||
|
"_bscan_start_freq_mhz",
|
||||||
|
"_bscan_stop_freq_mhz",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sync_gpr_frequency_limits_with_radar(self) -> bool:
|
||||||
|
"""Synchronize GPR start/stop MHz widget ranges with radar sweep bounds."""
|
||||||
|
return self._sync_frequency_spinboxes_with_radar(
|
||||||
|
(
|
||||||
|
"_gpr_start_freq_mhz",
|
||||||
|
"_gpr_stop_freq_mhz",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _switches_are_effectively_static(config: RunConfigModel) -> bool:
|
def _switches_are_effectively_static(config: RunConfigModel) -> bool:
|
||||||
"""Return `True` when switch setup effectively yields one fixed combo."""
|
"""Return `True` when switch setup effectively yields one fixed combo."""
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ class AppWindowPipelineMixin:
|
|||||||
"""Reset runtime history and B-scan caches."""
|
"""Reset runtime history and B-scan caches."""
|
||||||
self._replace_runtime_history(retained_raw=[], retained_pre=[], retained_result=[])
|
self._replace_runtime_history(retained_raw=[], retained_pre=[], retained_result=[])
|
||||||
self._bscan_history_floor_collection_id = 0
|
self._bscan_history_floor_collection_id = 0
|
||||||
self._clear_bscan_plot_history()
|
self._clear_history_mode_caches()
|
||||||
self._update_history_indicator()
|
self._update_history_indicator()
|
||||||
|
|
||||||
def _replace_runtime_history(
|
def _replace_runtime_history(
|
||||||
@@ -385,4 +385,8 @@ class AppWindowPipelineMixin:
|
|||||||
|
|
||||||
def _validate_processing_mode_constraints(self, config: RunConfigModel) -> None:
|
def _validate_processing_mode_constraints(self, config: RunConfigModel) -> None:
|
||||||
"""Validate processing-mode constraints for run start."""
|
"""Validate processing-mode constraints for run start."""
|
||||||
validate_processing_mode_constraints(self._processing_mode.currentText(), config)
|
validate_processing_mode_constraints(
|
||||||
|
self._processing_mode.currentText(),
|
||||||
|
config,
|
||||||
|
self._live_processing_config(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ class AppWindowPlotMixin:
|
|||||||
"""Draw collection based on currently selected processing mode."""
|
"""Draw collection based on currently selected processing mode."""
|
||||||
if self._processing_mode.currentText() == "bscan":
|
if self._processing_mode.currentText() == "bscan":
|
||||||
return self._draw_bscan_heatmap(collection)
|
return self._draw_bscan_heatmap(collection)
|
||||||
|
if self._processing_mode.currentText() == "gpr":
|
||||||
|
return self._draw_gpr_map(collection)
|
||||||
return self._draw_trace_lines(collection)
|
return self._draw_trace_lines(collection)
|
||||||
|
|
||||||
def _show_magnitude_curves(self) -> bool:
|
def _show_magnitude_curves(self) -> bool:
|
||||||
@@ -46,9 +48,24 @@ class AppWindowPlotMixin:
|
|||||||
"""Return whether phase curves should be rendered."""
|
"""Return whether phase curves should be rendered."""
|
||||||
return self._show_phase_checkbox.isChecked()
|
return self._show_phase_checkbox.isChecked()
|
||||||
|
|
||||||
|
def _pass_through_fixed_y_range(self) -> tuple[bool, float, float]:
|
||||||
|
"""Return normalized magnitude Y-range override for pass-through mode."""
|
||||||
|
y_min = float(self._pass_through_y_min_db.value())
|
||||||
|
y_max = float(self._pass_through_y_max_db.value())
|
||||||
|
return bool(self._pass_through_fixed_y_enabled.isChecked()), min(y_min, y_max), max(y_min, y_max)
|
||||||
|
|
||||||
|
def _configure_pass_through_magnitude_axis(self, plot: pg.PlotWidget) -> None:
|
||||||
|
"""Apply pass-through magnitude-axis autorange or fixed Y window."""
|
||||||
|
fixed_y_enabled, y_min, y_max = self._pass_through_fixed_y_range()
|
||||||
|
view_box = plot.getViewBox()
|
||||||
|
view_box.invertY(False)
|
||||||
|
view_box.enableAutoRange(x=True, y=not fixed_y_enabled)
|
||||||
|
if fixed_y_enabled:
|
||||||
|
plot.setYRange(y_min, y_max, padding=0.0)
|
||||||
|
|
||||||
def _on_trace_visibility_changed(self, *_args) -> None:
|
def _on_trace_visibility_changed(self, *_args) -> None:
|
||||||
"""Redraw pass-through traces when magnitude/phase toggles changed."""
|
"""Redraw pass-through traces when magnitude/phase toggles changed."""
|
||||||
if self._processing_mode.currentText() == "bscan":
|
if self._processing_mode.currentText() in {"bscan", "gpr"}:
|
||||||
return
|
return
|
||||||
if self._result_history:
|
if self._result_history:
|
||||||
self._draw_results(self._result_history[-1])
|
self._draw_results(self._result_history[-1])
|
||||||
@@ -98,8 +115,7 @@ class AppWindowPlotMixin:
|
|||||||
|
|
||||||
if show_magnitude:
|
if show_magnitude:
|
||||||
mag_item = magnitude_plot.getPlotItem()
|
mag_item = magnitude_plot.getPlotItem()
|
||||||
magnitude_plot.getViewBox().invertY(False)
|
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||||
magnitude_plot.getViewBox().enableAutoRange(x=True, y=True)
|
|
||||||
mag_item.showAxis("left", show=True)
|
mag_item.showAxis("left", show=True)
|
||||||
mag_item.showAxis("bottom", show=not show_phase)
|
mag_item.showAxis("bottom", show=not show_phase)
|
||||||
magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
||||||
@@ -219,6 +235,8 @@ class AppWindowPlotMixin:
|
|||||||
phase_plot.setXRange(x_min, x_max, padding=0.02)
|
phase_plot.setXRange(x_min, x_max, padding=0.02)
|
||||||
if show_phase:
|
if show_phase:
|
||||||
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
|
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
|
||||||
|
if show_magnitude:
|
||||||
|
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||||
return has_data
|
return has_data
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -481,8 +499,290 @@ class AppWindowPlotMixin:
|
|||||||
"""Hide right axis and clear phase overlay when phase is not rendered."""
|
"""Hide right axis and clear phase overlay when phase is not rendered."""
|
||||||
self._clear_phase_overlay()
|
self._clear_phase_overlay()
|
||||||
|
|
||||||
|
def _clear_gpr_plot(self) -> None:
|
||||||
|
"""Clear latest GPR plot surface."""
|
||||||
|
if not hasattr(self, "_gpr_plot"):
|
||||||
|
return
|
||||||
|
self._clear_gpr_point_labels()
|
||||||
|
self._clear_gpr_region_labels()
|
||||||
|
self._clear_gpr_region_masks()
|
||||||
|
if self._gpr_image_item is not None:
|
||||||
|
self._gpr_image_item.hide()
|
||||||
|
if self._gpr_tx_item is not None:
|
||||||
|
self._gpr_tx_item.setData(x=[], y=[])
|
||||||
|
self._gpr_tx_item.hide()
|
||||||
|
if self._gpr_rx_item is not None:
|
||||||
|
self._gpr_rx_item.setData(x=[], y=[])
|
||||||
|
self._gpr_rx_item.hide()
|
||||||
|
if self._gpr_points_item is not None:
|
||||||
|
self._gpr_points_item.setData(x=[], y=[])
|
||||||
|
self._gpr_points_item.hide()
|
||||||
|
if self._gpr_region_centers_item is not None:
|
||||||
|
self._gpr_region_centers_item.setData(x=[], y=[])
|
||||||
|
self._gpr_region_centers_item.hide()
|
||||||
|
self._gpr_plot.setTitle(f"GPR {self._gpr_config_mode.currentText()}")
|
||||||
|
|
||||||
|
def _ensure_gpr_plot_items(self) -> None:
|
||||||
|
"""Create persistent GPR plot items once and reuse them on redraw."""
|
||||||
|
if self._gpr_image_item is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
plot = self._gpr_plot
|
||||||
|
plot_item = plot.getPlotItem()
|
||||||
|
plot_item.showAxis("left", show=True)
|
||||||
|
plot_item.showAxis("bottom", show=True)
|
||||||
|
plot_item.setClipToView(True)
|
||||||
|
plot.setLabel("bottom", "X", units="m")
|
||||||
|
plot.setLabel("left", "Depth", units="m")
|
||||||
|
|
||||||
|
view_box = plot.getViewBox()
|
||||||
|
view_box.invertY(True)
|
||||||
|
view_box.enableAutoRange(x=False, y=False)
|
||||||
|
|
||||||
|
if self._gpr_lookup_table is None:
|
||||||
|
self._gpr_lookup_table = self._build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
|
||||||
|
|
||||||
|
self._gpr_image_item = pg.ImageItem(axisOrder="row-major")
|
||||||
|
self._gpr_image_item.setZValue(0)
|
||||||
|
self._gpr_image_item.hide()
|
||||||
|
plot.addItem(self._gpr_image_item)
|
||||||
|
|
||||||
|
self._gpr_tx_item = pg.ScatterPlotItem()
|
||||||
|
self._gpr_tx_item.setZValue(20)
|
||||||
|
self._gpr_tx_item.hide()
|
||||||
|
plot.addItem(self._gpr_tx_item)
|
||||||
|
|
||||||
|
self._gpr_rx_item = pg.ScatterPlotItem()
|
||||||
|
self._gpr_rx_item.setZValue(20)
|
||||||
|
self._gpr_rx_item.hide()
|
||||||
|
plot.addItem(self._gpr_rx_item)
|
||||||
|
|
||||||
|
self._gpr_points_item = pg.ScatterPlotItem()
|
||||||
|
self._gpr_points_item.setZValue(30)
|
||||||
|
self._gpr_points_item.hide()
|
||||||
|
plot.addItem(self._gpr_points_item)
|
||||||
|
|
||||||
|
self._gpr_region_centers_item = pg.ScatterPlotItem()
|
||||||
|
self._gpr_region_centers_item.setZValue(30)
|
||||||
|
self._gpr_region_centers_item.hide()
|
||||||
|
plot.addItem(self._gpr_region_centers_item)
|
||||||
|
|
||||||
|
def _clear_gpr_point_labels(self) -> None:
|
||||||
|
"""Remove dynamic point-score labels from GPR plot."""
|
||||||
|
for item in self._gpr_point_labels:
|
||||||
|
try:
|
||||||
|
self._gpr_plot.removeItem(item)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
self._gpr_point_labels.clear()
|
||||||
|
|
||||||
|
def _clear_gpr_region_labels(self) -> None:
|
||||||
|
"""Remove dynamic region labels from GPR plot."""
|
||||||
|
for item in self._gpr_region_center_labels:
|
||||||
|
try:
|
||||||
|
self._gpr_plot.removeItem(item)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
self._gpr_region_center_labels.clear()
|
||||||
|
|
||||||
|
def _clear_gpr_region_masks(self) -> None:
|
||||||
|
"""Remove dynamic region contour carriers from GPR plot."""
|
||||||
|
for item in self._gpr_region_mask_items:
|
||||||
|
try:
|
||||||
|
self._gpr_plot.removeItem(item)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
self._gpr_region_mask_items.clear()
|
||||||
|
self._gpr_region_contours.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collection_payload_by_name(collection: ResultCollection, name: str, kind: int | None = None):
|
||||||
|
"""Return first collection payload matching name and optional kind."""
|
||||||
|
for payload in collection.collection_payloads:
|
||||||
|
if payload.processing_name != name:
|
||||||
|
continue
|
||||||
|
if kind is not None and int(payload.kind) != int(kind):
|
||||||
|
continue
|
||||||
|
return payload
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collection_payloads_by_prefix(collection: ResultCollection, prefix: str, kind: int | None = None):
|
||||||
|
"""Return collection payloads matching processing-name prefix."""
|
||||||
|
payloads = []
|
||||||
|
for payload in collection.collection_payloads:
|
||||||
|
if not str(payload.processing_name).startswith(prefix):
|
||||||
|
continue
|
||||||
|
if kind is not None and int(payload.kind) != int(kind):
|
||||||
|
continue
|
||||||
|
payloads.append(payload)
|
||||||
|
return payloads
|
||||||
|
|
||||||
|
def _selected_gpr_geometry(self) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Resolve selected Tx/Rx geometry arrays for current GPR selection."""
|
||||||
|
requested_inputs = tuple(self._parse_csv_int_list(self._gpr_input_positions_input.text()))
|
||||||
|
requested_outputs = tuple(self._parse_csv_int_list(self._gpr_output_positions_input.text()))
|
||||||
|
signature = (
|
||||||
|
self._gpr_tx_geometry_input.toPlainText(),
|
||||||
|
self._gpr_rx_geometry_input.toPlainText(),
|
||||||
|
requested_inputs,
|
||||||
|
requested_outputs,
|
||||||
|
)
|
||||||
|
if signature == self._gpr_geometry_signature and self._gpr_selected_geometry is not None:
|
||||||
|
return self._gpr_selected_geometry
|
||||||
|
|
||||||
|
tx_entries = self._parse_gpr_tx_geometry_text(signature[0])
|
||||||
|
rx_entries = self._parse_gpr_rx_geometry_text(signature[1])
|
||||||
|
requested_input_set = set(requested_inputs)
|
||||||
|
requested_output_set = set(requested_outputs)
|
||||||
|
|
||||||
|
rx_entries = sorted(rx_entries, key=lambda entry: int(entry.input_pos))
|
||||||
|
tx_entries = sorted(tx_entries, key=lambda entry: int(entry.output_pos))
|
||||||
|
if requested_input_set:
|
||||||
|
rx_entries = [entry for entry in rx_entries if int(entry.input_pos) in requested_input_set]
|
||||||
|
if requested_output_set:
|
||||||
|
tx_entries = [entry for entry in tx_entries if int(entry.output_pos) in requested_output_set]
|
||||||
|
|
||||||
|
x_tx = np.asarray([float(entry.x_m) for entry in tx_entries], dtype=np.float32)
|
||||||
|
x_rx = np.asarray([float(entry.x_m) for entry in rx_entries], dtype=np.float32)
|
||||||
|
self._gpr_geometry_signature = signature
|
||||||
|
self._gpr_selected_geometry = (x_tx, x_rx)
|
||||||
|
return self._gpr_selected_geometry
|
||||||
|
|
||||||
|
def _draw_gpr_map(self, collection: ResultCollection) -> bool:
|
||||||
|
"""Draw latest collection-level GPR accumulator and annotations."""
|
||||||
|
accumulator_payload = self._collection_payload_by_name(collection, "gpr_accumulator", kind=3)
|
||||||
|
if accumulator_payload is None:
|
||||||
|
self._clear_gpr_plot()
|
||||||
|
return False
|
||||||
|
|
||||||
|
image = np.asarray(accumulator_payload.image, dtype=np.float32)
|
||||||
|
x_axis = np.asarray(accumulator_payload.image_x_axis, dtype=np.float32)
|
||||||
|
y_axis = np.asarray(accumulator_payload.image_y_axis, dtype=np.float32)
|
||||||
|
if image.ndim != 2 or image.size == 0 or x_axis.size == 0 or y_axis.size == 0:
|
||||||
|
self._clear_gpr_plot()
|
||||||
|
return False
|
||||||
|
|
||||||
|
x_min = float(x_axis[0])
|
||||||
|
x_max = float(x_axis[-1])
|
||||||
|
y_min = float(y_axis[0])
|
||||||
|
y_max = float(y_axis[-1])
|
||||||
|
rect = QRectF(x_min, y_min, max(x_max - x_min, 1e-6), max(y_max - y_min, 1e-6))
|
||||||
|
|
||||||
|
plot = self._gpr_plot
|
||||||
|
plot.setUpdatesEnabled(False)
|
||||||
|
try:
|
||||||
|
self._ensure_gpr_plot_items()
|
||||||
|
self._clear_gpr_point_labels()
|
||||||
|
self._clear_gpr_region_labels()
|
||||||
|
self._clear_gpr_region_masks()
|
||||||
|
|
||||||
|
self._gpr_image_item.setImage(image, autoLevels=False)
|
||||||
|
self._gpr_image_item.setRect(rect)
|
||||||
|
self._gpr_image_item.setLookupTable(self._gpr_lookup_table)
|
||||||
|
self._gpr_image_item.setLevels((float(np.min(image)), float(np.max(image) + 1e-6)))
|
||||||
|
self._gpr_image_item.show()
|
||||||
|
|
||||||
|
plot.setXRange(x_min, x_max, padding=0.02)
|
||||||
|
plot.setYRange(y_min, y_max, padding=0.02)
|
||||||
|
|
||||||
|
x_tx, x_rx = self._selected_gpr_geometry()
|
||||||
|
if x_tx.size > 0:
|
||||||
|
self._gpr_tx_item.setData(
|
||||||
|
x=x_tx,
|
||||||
|
y=np.zeros_like(x_tx),
|
||||||
|
symbol="t",
|
||||||
|
size=13,
|
||||||
|
brush=pg.mkBrush("#ff595e"),
|
||||||
|
pen=pg.mkPen("#ffca3a", width=1.0),
|
||||||
|
)
|
||||||
|
self._gpr_tx_item.show()
|
||||||
|
else:
|
||||||
|
self._gpr_tx_item.setData(x=[], y=[])
|
||||||
|
self._gpr_tx_item.hide()
|
||||||
|
|
||||||
|
if x_rx.size > 0:
|
||||||
|
self._gpr_rx_item.setData(
|
||||||
|
x=x_rx,
|
||||||
|
y=np.zeros_like(x_rx),
|
||||||
|
symbol="t1",
|
||||||
|
size=13,
|
||||||
|
brush=pg.mkBrush("#4cc9f0"),
|
||||||
|
pen=pg.mkPen("#e0fbfc", width=1.0),
|
||||||
|
)
|
||||||
|
self._gpr_rx_item.show()
|
||||||
|
else:
|
||||||
|
self._gpr_rx_item.setData(x=[], y=[])
|
||||||
|
self._gpr_rx_item.hide()
|
||||||
|
|
||||||
|
points_payload = self._collection_payload_by_name(collection, "gpr_points", kind=4)
|
||||||
|
if points_payload is not None and np.asarray(points_payload.table).size > 0:
|
||||||
|
points = np.asarray(points_payload.table, dtype=np.float32)
|
||||||
|
self._gpr_points_item.setData(
|
||||||
|
x=points[:, 0],
|
||||||
|
y=points[:, 1],
|
||||||
|
symbol="d",
|
||||||
|
size=11,
|
||||||
|
brush=pg.mkBrush("#ffffff"),
|
||||||
|
pen=pg.mkPen("#111111", width=1.1),
|
||||||
|
)
|
||||||
|
self._gpr_points_item.show()
|
||||||
|
for x_value, y_value, score in points:
|
||||||
|
label = pg.TextItem(text=f"{float(score):.0f}", color="#ffffff", anchor=(0.0, 1.0))
|
||||||
|
label.setZValue(40)
|
||||||
|
label.setPos(float(x_value), float(y_value))
|
||||||
|
plot.addItem(label)
|
||||||
|
self._gpr_point_labels.append(label)
|
||||||
|
else:
|
||||||
|
self._gpr_points_item.setData(x=[], y=[])
|
||||||
|
self._gpr_points_item.hide()
|
||||||
|
|
||||||
|
region_centers_payload = self._collection_payload_by_name(collection, "gpr_region_centers", kind=4)
|
||||||
|
if region_centers_payload is not None and np.asarray(region_centers_payload.table).size > 0:
|
||||||
|
centers = np.asarray(region_centers_payload.table, dtype=np.float32)
|
||||||
|
self._gpr_region_centers_item.setData(
|
||||||
|
x=centers[:, 0],
|
||||||
|
y=centers[:, 1],
|
||||||
|
symbol="o",
|
||||||
|
size=10,
|
||||||
|
brush=pg.mkBrush("#80ed99"),
|
||||||
|
pen=pg.mkPen("#081c15", width=1.1),
|
||||||
|
)
|
||||||
|
self._gpr_region_centers_item.show()
|
||||||
|
for row in centers:
|
||||||
|
label = pg.TextItem(text=f"{float(row[2]):.0f}", color="#d8f3dc", anchor=(0.0, 1.0))
|
||||||
|
label.setZValue(40)
|
||||||
|
label.setPos(float(row[0]), float(row[1]))
|
||||||
|
plot.addItem(label)
|
||||||
|
self._gpr_region_center_labels.append(label)
|
||||||
|
else:
|
||||||
|
self._gpr_region_centers_item.setData(x=[], y=[])
|
||||||
|
self._gpr_region_centers_item.hide()
|
||||||
|
|
||||||
|
for payload in self._collection_payloads_by_prefix(collection, "gpr_region_mask_", kind=3):
|
||||||
|
mask = np.asarray(payload.image, dtype=np.float32)
|
||||||
|
if mask.ndim != 2 or mask.size == 0:
|
||||||
|
continue
|
||||||
|
mask_image = pg.ImageItem(axisOrder="row-major")
|
||||||
|
mask_image.setZValue(5)
|
||||||
|
mask_image.setImage(mask, autoLevels=False)
|
||||||
|
mask_image.setRect(rect)
|
||||||
|
mask_image.setOpacity(0.0)
|
||||||
|
plot.addItem(mask_image)
|
||||||
|
contour = pg.IsocurveItem(data=mask, level=0.5, pen=pg.mkPen("#4cc9f0", width=1.3))
|
||||||
|
contour.setParentItem(mask_image)
|
||||||
|
self._gpr_region_mask_items.append(mask_image)
|
||||||
|
self._gpr_region_contours.append(contour)
|
||||||
|
|
||||||
|
plot.setTitle(f"GPR {self._gpr_config_mode.currentText()}")
|
||||||
|
finally:
|
||||||
|
plot.setUpdatesEnabled(True)
|
||||||
|
return True
|
||||||
|
|
||||||
def _result_collection_has_trace(self, collection: ResultCollection) -> bool:
|
def _result_collection_has_trace(self, collection: ResultCollection) -> bool:
|
||||||
"""Return `True` when collection contains at least one trace payload."""
|
"""Return `True` when collection contains at least one trace payload."""
|
||||||
|
if collection.collection_payloads:
|
||||||
|
return True
|
||||||
for block in collection.blocks:
|
for block in collection.blocks:
|
||||||
for payload in block.payloads:
|
for payload in block.payloads:
|
||||||
if payload.kind == 1 and payload.trace.size > 0:
|
if payload.kind == 1 and payload.trace.size > 0:
|
||||||
@@ -503,8 +803,7 @@ class AppWindowPlotMixin:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if show_magnitude:
|
if show_magnitude:
|
||||||
magnitude_plot.getViewBox().invertY(False)
|
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||||
magnitude_plot.getViewBox().enableAutoRange(x=True, y=True)
|
|
||||||
magnitude_plot.getPlotItem().showAxis("bottom", show=not show_phase)
|
magnitude_plot.getPlotItem().showAxis("bottom", show=not show_phase)
|
||||||
magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
magnitude_plot.setLabel("left", "Magnitude", units="dB")
|
||||||
magnitude_plot.setTitle(title)
|
magnitude_plot.setTitle(title)
|
||||||
@@ -548,5 +847,6 @@ class AppWindowPlotMixin:
|
|||||||
x_max = float(np.max(trace.frequency_hz))
|
x_max = float(np.max(trace.frequency_hz))
|
||||||
if show_magnitude:
|
if show_magnitude:
|
||||||
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
|
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
|
||||||
|
self._configure_pass_through_magnitude_axis(magnitude_plot)
|
||||||
if show_phase:
|
if show_phase:
|
||||||
phase_plot.setXRange(x_min, x_max, padding=0.02)
|
phase_plot.setXRange(x_min, x_max, padding=0.02)
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class AppWindowSnapshotMixin:
|
|||||||
|
|
||||||
self._replace_runtime_history(retained_raw=[], retained_pre=[], retained_result=[])
|
self._replace_runtime_history(retained_raw=[], retained_pre=[], retained_result=[])
|
||||||
self._bscan_history_floor_collection_id = 0
|
self._bscan_history_floor_collection_id = 0
|
||||||
self._clear_bscan_plot_history()
|
self._clear_history_mode_caches()
|
||||||
|
|
||||||
# Clear processor-side replay cache so newly rendered B-scan starts clean.
|
# Clear processor-side replay cache so newly rendered B-scan starts clean.
|
||||||
self._write_live_processing_config(history_command="clear_all", bump_history_seq=True)
|
self._write_live_processing_config(history_command="clear_all", bump_history_seq=True)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
Layout is intentionally split into two independent plot surfaces:
|
Layout is intentionally split into two independent plot surfaces:
|
||||||
- single `PlotWidget` for B-scan heatmap rendering;
|
- single `PlotWidget` for B-scan heatmap rendering;
|
||||||
|
- single `PlotWidget` for GPR accumulator/annotation rendering;
|
||||||
- stacked magnitude/phase `PlotWidget`s for pass-through traces.
|
- stacked magnitude/phase `PlotWidget`s for pass-through traces.
|
||||||
|
|
||||||
`_set_plot_mode()` switches between these surfaces via `QStackedWidget`.
|
`_set_plot_mode()` switches between these surfaces via `QStackedWidget`.
|
||||||
@@ -26,6 +27,7 @@ import pyqtgraph as pg
|
|||||||
|
|
||||||
from python_app.gui.controllers.sections import (
|
from python_app.gui.controllers.sections import (
|
||||||
build_data_actions_group,
|
build_data_actions_group,
|
||||||
|
build_gpr_config_group,
|
||||||
build_hardware_actions_group,
|
build_hardware_actions_group,
|
||||||
build_pipeline_group,
|
build_pipeline_group,
|
||||||
build_preprocess_summary_group,
|
build_preprocess_summary_group,
|
||||||
@@ -72,6 +74,7 @@ class AppWindowUiMixin:
|
|||||||
# We create both upfront and only switch active page at runtime.
|
# We create both upfront and only switch active page at runtime.
|
||||||
self._plot_stack = QStackedWidget(root)
|
self._plot_stack = QStackedWidget(root)
|
||||||
self._build_bscan_plot_page()
|
self._build_bscan_plot_page()
|
||||||
|
self._build_gpr_plot_page()
|
||||||
self._build_trace_plot_page()
|
self._build_trace_plot_page()
|
||||||
|
|
||||||
# Default view on startup is pass-through traces.
|
# Default view on startup is pass-through traces.
|
||||||
@@ -121,6 +124,12 @@ class AppWindowUiMixin:
|
|||||||
|
|
||||||
self._plot_stack.addWidget(self._trace_plots_container)
|
self._plot_stack.addWidget(self._trace_plots_container)
|
||||||
|
|
||||||
|
def _build_gpr_plot_page(self) -> None:
|
||||||
|
"""Create GPR page in plot stack."""
|
||||||
|
self._gpr_plot = pg.PlotWidget(background="#0f141c")
|
||||||
|
self._gpr_plot.showGrid(x=True, y=True, alpha=0.2)
|
||||||
|
self._plot_stack.addWidget(self._gpr_plot)
|
||||||
|
|
||||||
def _build_settings_toggle(self, root_layout: QHBoxLayout) -> None:
|
def _build_settings_toggle(self, root_layout: QHBoxLayout) -> None:
|
||||||
"""Create narrow button used to collapse or show settings panel."""
|
"""Create narrow button used to collapse or show settings panel."""
|
||||||
self._settings_toggle_button = QPushButton("<")
|
self._settings_toggle_button = QPushButton("<")
|
||||||
@@ -182,6 +191,7 @@ class AppWindowUiMixin:
|
|||||||
build_data_actions_group(self),
|
build_data_actions_group(self),
|
||||||
build_preprocess_summary_group(self),
|
build_preprocess_summary_group(self),
|
||||||
build_processing_group(self),
|
build_processing_group(self),
|
||||||
|
build_gpr_config_group(self),
|
||||||
build_radar_group(self),
|
build_radar_group(self),
|
||||||
build_switch_group(self),
|
build_switch_group(self),
|
||||||
]
|
]
|
||||||
@@ -205,12 +215,16 @@ class AppWindowUiMixin:
|
|||||||
def _set_plot_mode(self, mode: str) -> None:
|
def _set_plot_mode(self, mode: str) -> None:
|
||||||
"""Switch visible plot page according to processing mode.
|
"""Switch visible plot page according to processing mode.
|
||||||
|
|
||||||
`bscan` -> show `self._bscan_plot` (single heatmap surface)
|
`bscan` -> show `self._bscan_plot`
|
||||||
otherwise -> show `self._trace_plots_container` (magnitude + phase)
|
`gpr` -> show `self._gpr_plot`
|
||||||
|
otherwise -> show `self._trace_plots_container`
|
||||||
"""
|
"""
|
||||||
if mode == "bscan":
|
if mode == "bscan":
|
||||||
self._plot_stack.setCurrentWidget(self._bscan_plot)
|
self._plot_stack.setCurrentWidget(self._bscan_plot)
|
||||||
return
|
return
|
||||||
|
if mode == "gpr":
|
||||||
|
self._plot_stack.setCurrentWidget(self._gpr_plot)
|
||||||
|
return
|
||||||
self._plot_stack.setCurrentWidget(self._trace_plots_container)
|
self._plot_stack.setCurrentWidget(self._trace_plots_container)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Composable UI section builders used by AppWindow UI mixin."""
|
"""Composable UI section builders used by AppWindow UI mixin."""
|
||||||
|
|
||||||
from python_app.gui.controllers.sections.data_actions_section import build_data_actions_group
|
from python_app.gui.controllers.sections.data_actions_section import build_data_actions_group
|
||||||
|
from python_app.gui.controllers.sections.gpr_config_section import build_gpr_config_group
|
||||||
from python_app.gui.controllers.sections.hardware_actions_section import build_hardware_actions_group
|
from python_app.gui.controllers.sections.hardware_actions_section import build_hardware_actions_group
|
||||||
from python_app.gui.controllers.sections.pipeline_section import build_pipeline_group
|
from python_app.gui.controllers.sections.pipeline_section import build_pipeline_group
|
||||||
from python_app.gui.controllers.sections.preprocess_summary_section import build_preprocess_summary_group
|
from python_app.gui.controllers.sections.preprocess_summary_section import build_preprocess_summary_group
|
||||||
@@ -10,6 +11,7 @@ from python_app.gui.controllers.sections.switch_section import build_switch_grou
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"build_data_actions_group",
|
"build_data_actions_group",
|
||||||
|
"build_gpr_config_group",
|
||||||
"build_hardware_actions_group",
|
"build_hardware_actions_group",
|
||||||
"build_pipeline_group",
|
"build_pipeline_group",
|
||||||
"build_preprocess_summary_group",
|
"build_preprocess_summary_group",
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Builder for stable GPR configuration section."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QComboBox, QDoubleSpinBox, QFormLayout, QGroupBox, QPlainTextEdit
|
||||||
|
|
||||||
|
|
||||||
|
def _format_tx_geometry(owner) -> str:
|
||||||
|
"""Render Tx geometry defaults into editable line-based text."""
|
||||||
|
return "\n".join(
|
||||||
|
f"{int(entry.output_pos)} {float(entry.x_m):g}"
|
||||||
|
for entry in owner._defaults_config.gpr.tx_geometry
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_rx_geometry(owner) -> str:
|
||||||
|
"""Render Rx geometry defaults into editable line-based text."""
|
||||||
|
return "\n".join(
|
||||||
|
f"{int(entry.input_pos)} {float(entry.x_m):g}"
|
||||||
|
for entry in owner._defaults_config.gpr.rx_geometry
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_gpr_config_group(owner) -> QGroupBox:
|
||||||
|
"""Create stable GPR config controls backed by run_config.json."""
|
||||||
|
group = QGroupBox("GPR Config")
|
||||||
|
form = QFormLayout(group)
|
||||||
|
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||||
|
defaults = owner._defaults_config.gpr
|
||||||
|
|
||||||
|
owner._gpr_config_mode = QComboBox()
|
||||||
|
owner._gpr_config_mode.addItems(["point", "extended"])
|
||||||
|
owner._set_combo_current_text(owner._gpr_config_mode, defaults.mode)
|
||||||
|
|
||||||
|
owner._gpr_relative_permittivity = QDoubleSpinBox()
|
||||||
|
owner._gpr_relative_permittivity.setDecimals(4)
|
||||||
|
owner._gpr_relative_permittivity.setRange(0.0001, 1000.0)
|
||||||
|
owner._gpr_relative_permittivity.setSingleStep(0.05)
|
||||||
|
owner._gpr_relative_permittivity.setValue(float(defaults.relative_permittivity))
|
||||||
|
|
||||||
|
owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner))
|
||||||
|
owner._gpr_tx_geometry_input.setPlaceholderText("output_pos x_m")
|
||||||
|
owner._gpr_tx_geometry_input.setMinimumHeight(88)
|
||||||
|
|
||||||
|
owner._gpr_rx_geometry_input = QPlainTextEdit(_format_rx_geometry(owner))
|
||||||
|
owner._gpr_rx_geometry_input.setPlaceholderText("input_pos x_m")
|
||||||
|
owner._gpr_rx_geometry_input.setMinimumHeight(120)
|
||||||
|
|
||||||
|
form.addRow("Mode", owner._gpr_config_mode)
|
||||||
|
form.addRow("Relative Permittivity", owner._gpr_relative_permittivity)
|
||||||
|
form.addRow("Tx Geometry", owner._gpr_tx_geometry_input)
|
||||||
|
form.addRow("Rx Geometry", owner._gpr_rx_geometry_input)
|
||||||
|
return group
|
||||||
@@ -9,21 +9,39 @@ from PyQt6.QtWidgets import (
|
|||||||
QFormLayout,
|
QFormLayout,
|
||||||
QGroupBox,
|
QGroupBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
|
QLineEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
|
QSpinBox,
|
||||||
QStackedWidget,
|
QStackedWidget,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_gpr_input_positions(owner) -> str:
|
||||||
|
"""Build default live input-position selection from stable GPR config."""
|
||||||
|
geometry_values = {int(entry.input_pos) for entry in owner._defaults_config.gpr.rx_geometry}
|
||||||
|
combo_values = {int(combo.input) for combo in owner._defaults_config.combos}
|
||||||
|
values = sorted(geometry_values & combo_values) or sorted(geometry_values)
|
||||||
|
return ",".join(str(value) for value in values)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_gpr_output_positions(owner) -> str:
|
||||||
|
"""Build default live output-position selection from stable GPR config."""
|
||||||
|
geometry_values = {int(entry.output_pos) for entry in owner._defaults_config.gpr.tx_geometry}
|
||||||
|
combo_values = {int(combo.output) for combo in owner._defaults_config.combos}
|
||||||
|
values = sorted(geometry_values & combo_values) or sorted(geometry_values)
|
||||||
|
return ",".join(str(value) for value in values)
|
||||||
|
|
||||||
|
|
||||||
def build_processing_group(owner) -> QGroupBox:
|
def build_processing_group(owner) -> QGroupBox:
|
||||||
"""Create processing mode section with pass-through and B-scan pages."""
|
"""Create processing mode section with pass-through, B-scan, and GPR pages."""
|
||||||
group = QGroupBox("Processing")
|
group = QGroupBox("Processing")
|
||||||
form = QFormLayout(group)
|
form = QFormLayout(group)
|
||||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||||
|
|
||||||
owner._processing_mode = QComboBox()
|
owner._processing_mode = QComboBox()
|
||||||
owner._processing_mode.addItems(["pass_through", "bscan"])
|
owner._processing_mode.addItems(["pass_through", "bscan", "gpr"])
|
||||||
|
|
||||||
owner._processing_mode_pages = QStackedWidget(group)
|
owner._processing_mode_pages = QStackedWidget(group)
|
||||||
owner._processing_mode_pages.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
owner._processing_mode_pages.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||||
@@ -51,10 +69,35 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._show_phase_checkbox = QCheckBox("Show phase")
|
owner._show_phase_checkbox = QCheckBox("Show phase")
|
||||||
owner._show_phase_checkbox.setChecked(True)
|
owner._show_phase_checkbox.setChecked(True)
|
||||||
|
|
||||||
|
owner._pass_through_fixed_y_enabled = QCheckBox("Fix magnitude Y range")
|
||||||
|
owner._pass_through_fixed_y_enabled.setChecked(False)
|
||||||
|
|
||||||
|
owner._pass_through_y_min_db = QDoubleSpinBox()
|
||||||
|
owner._pass_through_y_min_db.setDecimals(1)
|
||||||
|
owner._pass_through_y_min_db.setRange(-240.0, 240.0)
|
||||||
|
owner._pass_through_y_min_db.setSingleStep(1.0)
|
||||||
|
owner._pass_through_y_min_db.setValue(-100.0)
|
||||||
|
|
||||||
|
owner._pass_through_y_max_db = QDoubleSpinBox()
|
||||||
|
owner._pass_through_y_max_db.setDecimals(1)
|
||||||
|
owner._pass_through_y_max_db.setRange(-240.0, 240.0)
|
||||||
|
owner._pass_through_y_max_db.setSingleStep(1.0)
|
||||||
|
owner._pass_through_y_max_db.setValue(0.0)
|
||||||
|
|
||||||
|
def sync_pass_through_y_controls() -> None:
|
||||||
|
enabled = owner._pass_through_fixed_y_enabled.isChecked()
|
||||||
|
owner._pass_through_y_min_db.setEnabled(enabled)
|
||||||
|
owner._pass_through_y_max_db.setEnabled(enabled)
|
||||||
|
|
||||||
|
sync_pass_through_y_controls()
|
||||||
|
|
||||||
pass_through_form.addRow("Gain dB (live)", owner._processing_gain_db)
|
pass_through_form.addRow("Gain dB (live)", owner._processing_gain_db)
|
||||||
pass_through_form.addRow("Phase deg (live)", owner._processing_phase_deg)
|
pass_through_form.addRow("Phase deg (live)", owner._processing_phase_deg)
|
||||||
pass_through_form.addRow(owner._show_magnitude_checkbox)
|
pass_through_form.addRow(owner._show_magnitude_checkbox)
|
||||||
pass_through_form.addRow(owner._show_phase_checkbox)
|
pass_through_form.addRow(owner._show_phase_checkbox)
|
||||||
|
pass_through_form.addRow(owner._pass_through_fixed_y_enabled)
|
||||||
|
pass_through_form.addRow("Y min dB", owner._pass_through_y_min_db)
|
||||||
|
pass_through_form.addRow("Y max dB", owner._pass_through_y_max_db)
|
||||||
owner._processing_mode_pages.addWidget(pass_through_page)
|
owner._processing_mode_pages.addWidget(pass_through_page)
|
||||||
|
|
||||||
bscan_page = QWidget(owner._processing_mode_pages)
|
bscan_page = QWidget(owner._processing_mode_pages)
|
||||||
@@ -115,11 +158,86 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
bscan_form.addRow(bscan_actions)
|
bscan_form.addRow(bscan_actions)
|
||||||
owner._processing_mode_pages.addWidget(bscan_page)
|
owner._processing_mode_pages.addWidget(bscan_page)
|
||||||
|
|
||||||
|
gpr_page = QWidget(owner._processing_mode_pages)
|
||||||
|
gpr_page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||||
|
gpr_form = QFormLayout(gpr_page)
|
||||||
|
gpr_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
||||||
|
|
||||||
|
owner._gpr_input_positions_input = QLineEdit(_default_gpr_input_positions(owner))
|
||||||
|
owner._gpr_input_positions_input.setPlaceholderText("0,1,2")
|
||||||
|
|
||||||
|
owner._gpr_output_positions_input = QLineEdit(_default_gpr_output_positions(owner))
|
||||||
|
owner._gpr_output_positions_input.setPlaceholderText("0,1")
|
||||||
|
|
||||||
|
owner._gpr_min_depth_m = QDoubleSpinBox()
|
||||||
|
owner._gpr_min_depth_m.setDecimals(2)
|
||||||
|
owner._gpr_min_depth_m.setRange(0.0, 50.0)
|
||||||
|
owner._gpr_min_depth_m.setSingleStep(0.1)
|
||||||
|
owner._gpr_min_depth_m.setValue(2.0)
|
||||||
|
|
||||||
|
owner._gpr_max_depth_m = QDoubleSpinBox()
|
||||||
|
owner._gpr_max_depth_m.setDecimals(2)
|
||||||
|
owner._gpr_max_depth_m.setRange(0.1, 50.0)
|
||||||
|
owner._gpr_max_depth_m.setSingleStep(0.1)
|
||||||
|
owner._gpr_max_depth_m.setValue(14.0)
|
||||||
|
|
||||||
|
owner._gpr_comp_power = QDoubleSpinBox()
|
||||||
|
owner._gpr_comp_power.setDecimals(3)
|
||||||
|
owner._gpr_comp_power.setRange(0.0, 5.0)
|
||||||
|
owner._gpr_comp_power.setSingleStep(0.05)
|
||||||
|
owner._gpr_comp_power.setValue(0.2)
|
||||||
|
|
||||||
|
owner._gpr_start_freq_mhz = QDoubleSpinBox()
|
||||||
|
owner._gpr_start_freq_mhz.setDecimals(1)
|
||||||
|
owner._gpr_start_freq_mhz.setRange(100.0, 8800.0)
|
||||||
|
owner._gpr_start_freq_mhz.setSingleStep(10.0)
|
||||||
|
owner._gpr_start_freq_mhz.setValue(3000.0)
|
||||||
|
|
||||||
|
owner._gpr_stop_freq_mhz = QDoubleSpinBox()
|
||||||
|
owner._gpr_stop_freq_mhz.setDecimals(1)
|
||||||
|
owner._gpr_stop_freq_mhz.setRange(100.0, 8800.0)
|
||||||
|
owner._gpr_stop_freq_mhz.setSingleStep(10.0)
|
||||||
|
owner._gpr_stop_freq_mhz.setValue(6000.0)
|
||||||
|
|
||||||
|
owner._gpr_background_subtract_enabled = QCheckBox("Subtract mean of previous collections")
|
||||||
|
owner._gpr_background_subtract_enabled.setChecked(True)
|
||||||
|
|
||||||
|
owner._gpr_background_mean_count = QSpinBox()
|
||||||
|
owner._gpr_background_mean_count.setRange(0, 10_000)
|
||||||
|
owner._gpr_background_mean_count.setValue(10)
|
||||||
|
|
||||||
|
owner._gpr_clear_history_button = QPushButton("Clear GPR History")
|
||||||
|
owner._gpr_clear_history_button.clicked.connect(owner._on_gpr_clear_history_clicked)
|
||||||
|
owner._gpr_remove_last_button = QPushButton("Remove Last Measurement")
|
||||||
|
owner._gpr_remove_last_button.clicked.connect(owner._on_gpr_remove_last_measurement_clicked)
|
||||||
|
gpr_actions = QWidget(owner._processing_mode_pages)
|
||||||
|
gpr_actions_layout = QHBoxLayout(gpr_actions)
|
||||||
|
gpr_actions_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
gpr_actions_layout.setSpacing(8)
|
||||||
|
gpr_actions_layout.addWidget(owner._gpr_remove_last_button)
|
||||||
|
gpr_actions_layout.addWidget(owner._gpr_clear_history_button)
|
||||||
|
|
||||||
|
gpr_form.addRow("Input positions", owner._gpr_input_positions_input)
|
||||||
|
gpr_form.addRow("Output positions", owner._gpr_output_positions_input)
|
||||||
|
gpr_form.addRow("Min depth m", owner._gpr_min_depth_m)
|
||||||
|
gpr_form.addRow("Max depth m", owner._gpr_max_depth_m)
|
||||||
|
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(owner._gpr_background_subtract_enabled)
|
||||||
|
gpr_form.addRow("Mean count", owner._gpr_background_mean_count)
|
||||||
|
gpr_form.addRow(gpr_actions)
|
||||||
|
owner._processing_mode_pages.addWidget(gpr_page)
|
||||||
|
|
||||||
owner._processing_mode.currentTextChanged.connect(owner._on_processing_mode_changed)
|
owner._processing_mode.currentTextChanged.connect(owner._on_processing_mode_changed)
|
||||||
owner._processing_gain_db.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._processing_gain_db.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._processing_phase_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._processing_phase_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._show_magnitude_checkbox.toggled.connect(owner._on_trace_visibility_changed)
|
owner._show_magnitude_checkbox.toggled.connect(owner._on_trace_visibility_changed)
|
||||||
owner._show_phase_checkbox.toggled.connect(owner._on_trace_visibility_changed)
|
owner._show_phase_checkbox.toggled.connect(owner._on_trace_visibility_changed)
|
||||||
|
owner._pass_through_fixed_y_enabled.toggled.connect(sync_pass_through_y_controls)
|
||||||
|
owner._pass_through_fixed_y_enabled.toggled.connect(owner._on_processing_live_settings_changed)
|
||||||
|
owner._pass_through_y_min_db.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
|
owner._pass_through_y_max_db.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
form.addRow("Mode", owner._processing_mode)
|
form.addRow("Mode", owner._processing_mode)
|
||||||
form.addRow(owner._processing_mode_pages)
|
form.addRow(owner._processing_mode_pages)
|
||||||
|
|
||||||
@@ -129,6 +247,15 @@ def build_processing_group(owner) -> QGroupBox:
|
|||||||
owner._bscan_gain.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._bscan_gain.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._bscan_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._bscan_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
owner._bscan_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
owner._bscan_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
|
owner._gpr_input_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed)
|
||||||
|
owner._gpr_output_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed)
|
||||||
|
owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
|
||||||
|
owner._gpr_max_depth_m.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_stop_freq_mhz.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)
|
||||||
|
|
||||||
owner._on_processing_mode_changed(owner._processing_mode.currentText())
|
owner._on_processing_mode_changed(owner._processing_mode.currentText())
|
||||||
return group
|
return group
|
||||||
|
|||||||
@@ -3,19 +3,53 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from python_app.models.run_config_model import RunConfigModel
|
from python_app.models.run_config_model import RunConfigModel
|
||||||
|
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
|
||||||
|
|
||||||
|
|
||||||
def validate_processing_mode_constraints(processing_mode: str, config: RunConfigModel) -> None:
|
def validate_processing_mode_constraints(
|
||||||
|
processing_mode: str,
|
||||||
|
config: RunConfigModel,
|
||||||
|
live_config: ProcessingLiveConfig,
|
||||||
|
) -> None:
|
||||||
"""Validate mode-specific constraints for current run configuration."""
|
"""Validate mode-specific constraints for current run configuration."""
|
||||||
if processing_mode != "bscan":
|
if processing_mode == "bscan":
|
||||||
|
any_native_switch = config.input_switch.driver_mode == "native" or config.output_switch.driver_mode == "native"
|
||||||
|
if not any_native_switch:
|
||||||
|
return
|
||||||
|
|
||||||
|
combo_count = len({(int(combo.input), int(combo.output)) for combo in config.combos})
|
||||||
|
if combo_count != 1:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"B-scan with native switches requires exactly one run combo (now {combo_count})"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
any_native_switch = config.input_switch.driver_mode == "native" or config.output_switch.driver_mode == "native"
|
if processing_mode != "gpr":
|
||||||
if not any_native_switch:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
combo_count = len({(int(combo.input), int(combo.output)) for combo in config.combos})
|
available_inputs = sorted({int(entry.input_pos) for entry in config.gpr.rx_geometry})
|
||||||
if combo_count != 1:
|
available_outputs = sorted({int(entry.output_pos) for entry in config.gpr.tx_geometry})
|
||||||
|
if not available_inputs or not available_outputs:
|
||||||
|
raise RuntimeError("GPR requires non-empty Tx/Rx geometry in run_config")
|
||||||
|
|
||||||
|
requested_inputs = sorted({int(value) for value in live_config.gpr_input_positions})
|
||||||
|
requested_outputs = sorted({int(value) for value in live_config.gpr_output_positions})
|
||||||
|
|
||||||
|
selected_inputs = requested_inputs or available_inputs
|
||||||
|
selected_outputs = requested_outputs or available_outputs
|
||||||
|
|
||||||
|
missing_inputs = [value for value in selected_inputs if value not in available_inputs]
|
||||||
|
missing_outputs = [value for value in selected_outputs if value not in available_outputs]
|
||||||
|
if missing_inputs:
|
||||||
|
raise RuntimeError(f"GPR input positions are missing from geometry config: {missing_inputs}")
|
||||||
|
if missing_outputs:
|
||||||
|
raise RuntimeError(f"GPR output positions are missing from geometry config: {missing_outputs}")
|
||||||
|
|
||||||
|
required_combos = {(int(input_pos), int(output_pos)) for input_pos in selected_inputs for output_pos in selected_outputs}
|
||||||
|
configured_combos = {(int(combo.input), int(combo.output)) for combo in config.combos}
|
||||||
|
missing_combos = sorted(required_combos - configured_combos)
|
||||||
|
if missing_combos:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"B-scan with native switches requires exactly one run combo (now {combo_count})"
|
"GPR run combos do not cover selected input/output positions: "
|
||||||
|
f"{missing_combos}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,21 @@ from dataclasses import dataclass, field
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_f32_array() -> np.ndarray:
|
||||||
|
"""Return empty float32 array used by payload defaults."""
|
||||||
|
return np.array([], dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_c64_array() -> np.ndarray:
|
||||||
|
"""Return empty complex64 array used by payload defaults."""
|
||||||
|
return np.array([], dtype=np.complex64)
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_f32_matrix() -> np.ndarray:
|
||||||
|
"""Return empty 2D float32 matrix used by payload defaults."""
|
||||||
|
return np.zeros((0, 0), dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ComboKey:
|
class ComboKey:
|
||||||
"""Switch combination key: input position + output position."""
|
"""Switch combination key: input position + output position."""
|
||||||
@@ -39,9 +54,13 @@ class ResultPayload:
|
|||||||
|
|
||||||
processing_name: str
|
processing_name: str
|
||||||
kind: int
|
kind: int
|
||||||
frequency_hz: np.ndarray
|
frequency_hz: np.ndarray = field(default_factory=_empty_f32_array)
|
||||||
trace: np.ndarray
|
trace: np.ndarray = field(default_factory=_empty_c64_array)
|
||||||
scalar_value: float = 0.0
|
scalar_value: float = 0.0
|
||||||
|
image_x_axis: np.ndarray = field(default_factory=_empty_f32_array)
|
||||||
|
image_y_axis: np.ndarray = field(default_factory=_empty_f32_array)
|
||||||
|
image: np.ndarray = field(default_factory=_empty_f32_matrix)
|
||||||
|
table: np.ndarray = field(default_factory=_empty_f32_matrix)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -58,4 +77,5 @@ class ResultCollection:
|
|||||||
|
|
||||||
collection_id: int
|
collection_id: int
|
||||||
monotonic_ns: int
|
monotonic_ns: int
|
||||||
|
collection_payloads: list[ResultPayload] = field(default_factory=list)
|
||||||
blocks: list[ResultBlock] = field(default_factory=list)
|
blocks: list[ResultBlock] = field(default_factory=list)
|
||||||
|
|||||||
@@ -4,8 +4,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from python_app.models.run_config_schema import ComboModel, RunConfigModel
|
from python_app.models.run_config_schema import (
|
||||||
from python_app.models.run_config_validation import load_ring_payload, load_switch_payload
|
ComboModel,
|
||||||
|
GprRxGeometryModel,
|
||||||
|
GprTxGeometryModel,
|
||||||
|
RunConfigModel,
|
||||||
|
)
|
||||||
|
from python_app.models.run_config_validation import load_ring_payload, load_switch_payload, validate_gpr_model
|
||||||
|
|
||||||
|
|
||||||
def _as_dict(value: Any, context: str) -> dict[str, Any]:
|
def _as_dict(value: Any, context: str) -> dict[str, Any]:
|
||||||
@@ -29,6 +34,7 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
|||||||
port2_payload = _as_dict(switches_payload.get("port2"), "switches.port2")
|
port2_payload = _as_dict(switches_payload.get("port2"), "switches.port2")
|
||||||
run_payload = _as_dict(payload.get("run"), "run")
|
run_payload = _as_dict(payload.get("run"), "run")
|
||||||
preprocess_payload = _as_dict(payload.get("preprocess"), "preprocess")
|
preprocess_payload = _as_dict(payload.get("preprocess"), "preprocess")
|
||||||
|
gpr_payload = _as_dict(payload.get("gpr"), "gpr")
|
||||||
rings_payload = _as_dict(payload.get("rings"), "rings")
|
rings_payload = _as_dict(payload.get("rings"), "rings")
|
||||||
raw_ring_payload = _as_dict(rings_payload.get("raw"), "rings.raw")
|
raw_ring_payload = _as_dict(rings_payload.get("raw"), "rings.raw")
|
||||||
raw_tap_ring_payload = _as_dict(rings_payload.get("raw_tap"), "rings.raw_tap")
|
raw_tap_ring_payload = _as_dict(rings_payload.get("raw_tap"), "rings.raw_tap")
|
||||||
@@ -68,6 +74,38 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
|||||||
preprocess_payload.get("reference_bundle_path", model.preprocess.reference_bundle_path)
|
preprocess_payload.get("reference_bundle_path", model.preprocess.reference_bundle_path)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
model.gpr.mode = str(gpr_payload.get("mode", model.gpr.mode))
|
||||||
|
model.gpr.relative_permittivity = float(
|
||||||
|
gpr_payload.get("relative_permittivity", model.gpr.relative_permittivity)
|
||||||
|
)
|
||||||
|
model.gpr.tx_geometry = []
|
||||||
|
tx_geometry_payload = gpr_payload.get("tx_geometry", [])
|
||||||
|
if isinstance(tx_geometry_payload, list):
|
||||||
|
for entry in tx_geometry_payload:
|
||||||
|
entry_payload = _as_dict(entry, "gpr.tx_geometry[]")
|
||||||
|
model.gpr.tx_geometry.append(
|
||||||
|
GprTxGeometryModel(
|
||||||
|
output_pos=int(entry_payload.get("output_pos", 0)),
|
||||||
|
x_m=float(entry_payload.get("x_m", 0.0)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
model.gpr.rx_geometry = []
|
||||||
|
rx_geometry_payload = gpr_payload.get("rx_geometry", [])
|
||||||
|
if isinstance(rx_geometry_payload, list):
|
||||||
|
for entry in rx_geometry_payload:
|
||||||
|
entry_payload = _as_dict(entry, "gpr.rx_geometry[]")
|
||||||
|
model.gpr.rx_geometry.append(
|
||||||
|
GprRxGeometryModel(
|
||||||
|
input_pos=int(entry_payload.get("input_pos", 0)),
|
||||||
|
x_m=float(entry_payload.get("x_m", 0.0)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
validate_gpr_model(
|
||||||
|
model.gpr,
|
||||||
|
input_switch_positions=model.input_switch.positions,
|
||||||
|
output_switch_positions=model.output_switch.positions,
|
||||||
|
)
|
||||||
|
|
||||||
load_ring_payload(raw_ring_payload, model.rings.raw)
|
load_ring_payload(raw_ring_payload, model.rings.raw)
|
||||||
load_ring_payload(raw_tap_ring_payload, model.rings.raw_tap)
|
load_ring_payload(raw_tap_ring_payload, model.rings.raw_tap)
|
||||||
load_ring_payload(pre_ring_payload, model.rings.preprocessed)
|
load_ring_payload(pre_ring_payload, model.rings.preprocessed)
|
||||||
@@ -145,6 +183,24 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
|||||||
"calibration_bundle_path": model.preprocess.calibration_bundle_path,
|
"calibration_bundle_path": model.preprocess.calibration_bundle_path,
|
||||||
"reference_bundle_path": model.preprocess.reference_bundle_path,
|
"reference_bundle_path": model.preprocess.reference_bundle_path,
|
||||||
},
|
},
|
||||||
|
"gpr": {
|
||||||
|
"mode": model.gpr.mode,
|
||||||
|
"relative_permittivity": model.gpr.relative_permittivity,
|
||||||
|
"tx_geometry": [
|
||||||
|
{
|
||||||
|
"output_pos": entry.output_pos,
|
||||||
|
"x_m": entry.x_m,
|
||||||
|
}
|
||||||
|
for entry in model.gpr.tx_geometry
|
||||||
|
],
|
||||||
|
"rx_geometry": [
|
||||||
|
{
|
||||||
|
"input_pos": entry.input_pos,
|
||||||
|
"x_m": entry.x_m,
|
||||||
|
}
|
||||||
|
for entry in model.gpr.rx_geometry
|
||||||
|
],
|
||||||
|
},
|
||||||
"rings": {
|
"rings": {
|
||||||
"raw": {
|
"raw": {
|
||||||
"name": model.rings.raw.name,
|
"name": model.rings.raw.name,
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
from python_app.models.run_config_codec import run_config_from_dict, run_config_to_dict
|
from python_app.models.run_config_codec import run_config_from_dict, run_config_to_dict
|
||||||
from python_app.models.run_config_schema import (
|
from python_app.models.run_config_schema import (
|
||||||
ComboModel,
|
ComboModel,
|
||||||
|
GprModel,
|
||||||
|
GprRxGeometryModel,
|
||||||
|
GprTxGeometryModel,
|
||||||
PreprocessModel,
|
PreprocessModel,
|
||||||
RadarModel,
|
RadarModel,
|
||||||
RadarSweepModel,
|
RadarSweepModel,
|
||||||
@@ -20,6 +23,9 @@ from python_app.models.run_config_validation import (
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ComboModel",
|
"ComboModel",
|
||||||
|
"GprModel",
|
||||||
|
"GprRxGeometryModel",
|
||||||
|
"GprTxGeometryModel",
|
||||||
"PreprocessModel",
|
"PreprocessModel",
|
||||||
"RadarModel",
|
"RadarModel",
|
||||||
"RadarSweepModel",
|
"RadarSweepModel",
|
||||||
|
|||||||
@@ -95,6 +95,32 @@ class PreprocessModel:
|
|||||||
reference_bundle_path: str = ""
|
reference_bundle_path: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class GprTxGeometryModel:
|
||||||
|
"""One transmitter geometry record keyed by output switch position."""
|
||||||
|
|
||||||
|
output_pos: int = 0
|
||||||
|
x_m: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class GprRxGeometryModel:
|
||||||
|
"""One receiver geometry record keyed by input switch position."""
|
||||||
|
|
||||||
|
input_pos: int = 0
|
||||||
|
x_m: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class GprModel:
|
||||||
|
"""Stable GPR configuration saved in run_config.json."""
|
||||||
|
|
||||||
|
mode: str = "point"
|
||||||
|
relative_permittivity: float = 1.0
|
||||||
|
tx_geometry: list[GprTxGeometryModel] = field(default_factory=list)
|
||||||
|
rx_geometry: list[GprRxGeometryModel] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class RunConfigModel:
|
class RunConfigModel:
|
||||||
"""Top-level runtime config model consumed by C++ processes and GUI."""
|
"""Top-level runtime config model consumed by C++ processes and GUI."""
|
||||||
@@ -105,6 +131,7 @@ class RunConfigModel:
|
|||||||
rings: RingsModel = field(default_factory=RingsModel)
|
rings: RingsModel = field(default_factory=RingsModel)
|
||||||
runtime: RuntimeModel = field(default_factory=RuntimeModel)
|
runtime: RuntimeModel = field(default_factory=RuntimeModel)
|
||||||
preprocess: PreprocessModel = field(default_factory=PreprocessModel)
|
preprocess: PreprocessModel = field(default_factory=PreprocessModel)
|
||||||
|
gpr: GprModel = field(default_factory=GprModel)
|
||||||
combos: list[ComboModel] = field(default_factory=list)
|
combos: list[ComboModel] = field(default_factory=list)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from python_app.models.run_config_schema import ComboModel, RingEndpointModel, SwitchModel
|
from python_app.models.run_config_schema import ComboModel, GprModel, RingEndpointModel, SwitchModel
|
||||||
|
|
||||||
def load_switch_payload(
|
def load_switch_payload(
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
@@ -30,6 +30,37 @@ def load_ring_payload(payload: dict[str, Any], target: RingEndpointModel) -> Non
|
|||||||
target.slot_size_bytes = int(payload.get("slot_size_bytes", target.slot_size_bytes))
|
target.slot_size_bytes = int(payload.get("slot_size_bytes", target.slot_size_bytes))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_gpr_model(
|
||||||
|
gpr: GprModel,
|
||||||
|
*,
|
||||||
|
input_switch_positions: int,
|
||||||
|
output_switch_positions: int,
|
||||||
|
) -> None:
|
||||||
|
"""Validate stable GPR config against current switch dimensions."""
|
||||||
|
if gpr.mode not in {"point", "extended"}:
|
||||||
|
raise ValueError("gpr.mode must be either 'point' or 'extended'")
|
||||||
|
if float(gpr.relative_permittivity) <= 0.0:
|
||||||
|
raise ValueError("gpr.relative_permittivity must be > 0")
|
||||||
|
|
||||||
|
seen_output_positions: set[int] = set()
|
||||||
|
for entry in gpr.tx_geometry:
|
||||||
|
output_pos = int(entry.output_pos)
|
||||||
|
if output_pos < 0 or output_pos >= int(output_switch_positions):
|
||||||
|
raise ValueError("gpr.tx_geometry output_pos is out of range")
|
||||||
|
if output_pos in seen_output_positions:
|
||||||
|
raise ValueError("gpr.tx_geometry contains duplicate output_pos")
|
||||||
|
seen_output_positions.add(output_pos)
|
||||||
|
|
||||||
|
seen_input_positions: set[int] = set()
|
||||||
|
for entry in gpr.rx_geometry:
|
||||||
|
input_pos = int(entry.input_pos)
|
||||||
|
if input_pos < 0 or input_pos >= int(input_switch_positions):
|
||||||
|
raise ValueError("gpr.rx_geometry input_pos is out of range")
|
||||||
|
if input_pos in seen_input_positions:
|
||||||
|
raise ValueError("gpr.rx_geometry contains duplicate input_pos")
|
||||||
|
seen_input_positions.add(input_pos)
|
||||||
|
|
||||||
|
|
||||||
def parse_combos_from_text(text: str) -> list[ComboModel]:
|
def parse_combos_from_text(text: str) -> list[ComboModel]:
|
||||||
"""Parse UI combos string in `input:output,input:output` format."""
|
"""Parse UI combos string in `input:output,input:output` format."""
|
||||||
cleaned = text.strip()
|
cleaned = text.strip()
|
||||||
|
|||||||
@@ -14,27 +14,62 @@ class ProcessingLiveConfig:
|
|||||||
processor_mode: str = "pass_through"
|
processor_mode: str = "pass_through"
|
||||||
gain_db: float = 0.0
|
gain_db: float = 0.0
|
||||||
phase_deg: float = 0.0
|
phase_deg: float = 0.0
|
||||||
|
pass_through_fixed_y_enabled: bool = False
|
||||||
|
pass_through_y_min_db: float = -100.0
|
||||||
|
pass_through_y_max_db: float = 0.0
|
||||||
bscan_axis: str = "abs"
|
bscan_axis: str = "abs"
|
||||||
bscan_cut_m: float = 0.824
|
bscan_cut_m: float = 0.824
|
||||||
bscan_max_depth_m: float = 1.0
|
bscan_max_depth_m: float = 1.0
|
||||||
bscan_gain: float = 1.0
|
bscan_gain: float = 1.0
|
||||||
bscan_start_freq_mhz: float = 100.0
|
bscan_start_freq_mhz: float = 100.0
|
||||||
bscan_stop_freq_mhz: float = 8800.0
|
bscan_stop_freq_mhz: float = 8800.0
|
||||||
|
gpr_input_positions: list[int] | None = None
|
||||||
|
gpr_output_positions: list[int] | None = None
|
||||||
|
gpr_min_depth_m: float = 2.0
|
||||||
|
gpr_max_depth_m: float = 14.0
|
||||||
|
gpr_comp_power: float = 0.2
|
||||||
|
gpr_start_freq_mhz: float = 3000.0
|
||||||
|
gpr_stop_freq_mhz: float = 6000.0
|
||||||
|
gpr_background_subtract_enabled: bool = True
|
||||||
|
gpr_background_mean_count: int = 10
|
||||||
history_command_seq: int = 0
|
history_command_seq: int = 0
|
||||||
history_command: str = "none"
|
history_command: str = "none"
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, float | str | int]:
|
def __post_init__(self) -> None:
|
||||||
|
"""Normalize optional list fields to concrete integer lists."""
|
||||||
|
if self.gpr_input_positions is None:
|
||||||
|
self.gpr_input_positions = []
|
||||||
|
else:
|
||||||
|
self.gpr_input_positions = [int(value) for value in self.gpr_input_positions]
|
||||||
|
if self.gpr_output_positions is None:
|
||||||
|
self.gpr_output_positions = []
|
||||||
|
else:
|
||||||
|
self.gpr_output_positions = [int(value) for value in self.gpr_output_positions]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
"""Convert live config to JSON-serializable dictionary."""
|
"""Convert live config to JSON-serializable dictionary."""
|
||||||
return {
|
return {
|
||||||
"processor_mode": str(self.processor_mode),
|
"processor_mode": str(self.processor_mode),
|
||||||
"gain_db": float(self.gain_db),
|
"gain_db": float(self.gain_db),
|
||||||
"phase_deg": float(self.phase_deg),
|
"phase_deg": float(self.phase_deg),
|
||||||
|
"pass_through_fixed_y_enabled": bool(self.pass_through_fixed_y_enabled),
|
||||||
|
"pass_through_y_min_db": float(self.pass_through_y_min_db),
|
||||||
|
"pass_through_y_max_db": float(self.pass_through_y_max_db),
|
||||||
"bscan_axis": str(self.bscan_axis),
|
"bscan_axis": str(self.bscan_axis),
|
||||||
"bscan_cut_m": float(self.bscan_cut_m),
|
"bscan_cut_m": float(self.bscan_cut_m),
|
||||||
"bscan_max_depth_m": float(self.bscan_max_depth_m),
|
"bscan_max_depth_m": float(self.bscan_max_depth_m),
|
||||||
"bscan_gain": float(self.bscan_gain),
|
"bscan_gain": float(self.bscan_gain),
|
||||||
"bscan_start_freq_mhz": float(self.bscan_start_freq_mhz),
|
"bscan_start_freq_mhz": float(self.bscan_start_freq_mhz),
|
||||||
"bscan_stop_freq_mhz": float(self.bscan_stop_freq_mhz),
|
"bscan_stop_freq_mhz": float(self.bscan_stop_freq_mhz),
|
||||||
|
"gpr_input_positions": [int(value) for value in self.gpr_input_positions],
|
||||||
|
"gpr_output_positions": [int(value) for value in self.gpr_output_positions],
|
||||||
|
"gpr_min_depth_m": float(self.gpr_min_depth_m),
|
||||||
|
"gpr_max_depth_m": float(self.gpr_max_depth_m),
|
||||||
|
"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_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),
|
"history_command_seq": int(self.history_command_seq),
|
||||||
"history_command": str(self.history_command),
|
"history_command": str(self.history_command),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,62 @@ def decode_trace_collection(payload: bytes, expected_magic: int) -> SweepCollect
|
|||||||
|
|
||||||
def decode_result_collection(payload: bytes) -> ResultCollection:
|
def decode_result_collection(payload: bytes) -> ResultCollection:
|
||||||
"""Decode one processed result collection from binary payload."""
|
"""Decode one processed result collection from binary payload."""
|
||||||
|
|
||||||
|
def read_payload(cursor: ByteCursor) -> ResultPayload:
|
||||||
|
"""Decode one result payload from stream."""
|
||||||
|
kind = cursor.read_u8()
|
||||||
|
name_size = cursor.read_u16()
|
||||||
|
name = cursor.read_bytes(name_size).decode("utf-8")
|
||||||
|
|
||||||
|
if kind == 1:
|
||||||
|
point_count = cursor.read_u32()
|
||||||
|
freq = np.frombuffer(cursor.read_bytes(point_count * 4), dtype="<f4").astype(np.float32, copy=False)
|
||||||
|
interleaved = np.frombuffer(cursor.read_bytes(point_count * 8), dtype="<f4")
|
||||||
|
trace = (interleaved[0::2] + 1j * interleaved[1::2]).astype(np.complex64, copy=False)
|
||||||
|
return ResultPayload(
|
||||||
|
processing_name=name,
|
||||||
|
kind=kind,
|
||||||
|
frequency_hz=freq,
|
||||||
|
trace=trace,
|
||||||
|
)
|
||||||
|
if kind == 2:
|
||||||
|
return ResultPayload(
|
||||||
|
processing_name=name,
|
||||||
|
kind=kind,
|
||||||
|
scalar_value=cursor.read_f32(),
|
||||||
|
)
|
||||||
|
if kind == 3:
|
||||||
|
x_count = cursor.read_u32()
|
||||||
|
y_count = cursor.read_u32()
|
||||||
|
image_x_axis = np.frombuffer(cursor.read_bytes(x_count * 4), dtype="<f4").astype(np.float32, copy=False)
|
||||||
|
image_y_axis = np.frombuffer(cursor.read_bytes(y_count * 4), dtype="<f4").astype(np.float32, copy=False)
|
||||||
|
value_count = x_count * y_count
|
||||||
|
image_values = np.frombuffer(cursor.read_bytes(value_count * 4), dtype="<f4").astype(np.float32, copy=False)
|
||||||
|
image = image_values.reshape((y_count, x_count)) if value_count > 0 else np.zeros((0, 0), dtype=np.float32)
|
||||||
|
return ResultPayload(
|
||||||
|
processing_name=name,
|
||||||
|
kind=kind,
|
||||||
|
image_x_axis=image_x_axis,
|
||||||
|
image_y_axis=image_y_axis,
|
||||||
|
image=image,
|
||||||
|
)
|
||||||
|
if kind == 4:
|
||||||
|
table_columns = cursor.read_u32()
|
||||||
|
table_rows = cursor.read_u32()
|
||||||
|
value_count = table_columns * table_rows
|
||||||
|
table_values = np.frombuffer(cursor.read_bytes(value_count * 4), dtype="<f4").astype(np.float32, copy=False)
|
||||||
|
table = (
|
||||||
|
table_values.reshape((table_rows, table_columns))
|
||||||
|
if value_count > 0 and table_columns > 0
|
||||||
|
else np.zeros((0, 0), dtype=np.float32)
|
||||||
|
)
|
||||||
|
return ResultPayload(
|
||||||
|
processing_name=name,
|
||||||
|
kind=kind,
|
||||||
|
table=table,
|
||||||
|
)
|
||||||
|
raise ValueError(f"Unsupported result payload kind: {kind}")
|
||||||
|
|
||||||
cursor = ByteCursor(payload)
|
cursor = ByteCursor(payload)
|
||||||
magic = cursor.read_u32()
|
magic = cursor.read_u32()
|
||||||
if magic != RESULT_MAGIC:
|
if magic != RESULT_MAGIC:
|
||||||
@@ -63,8 +119,13 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
|
|||||||
|
|
||||||
collection_id = cursor.read_u64()
|
collection_id = cursor.read_u64()
|
||||||
monotonic_ns = cursor.read_u64()
|
monotonic_ns = cursor.read_u64()
|
||||||
|
collection_payload_count = cursor.read_u32()
|
||||||
block_count = cursor.read_u32()
|
block_count = cursor.read_u32()
|
||||||
|
|
||||||
|
collection_payloads: list[ResultPayload] = []
|
||||||
|
for _ in range(collection_payload_count):
|
||||||
|
collection_payloads.append(read_payload(cursor))
|
||||||
|
|
||||||
blocks: list[ResultBlock] = []
|
blocks: list[ResultBlock] = []
|
||||||
for _ in range(block_count):
|
for _ in range(block_count):
|
||||||
input_pos = cursor.read_u32()
|
input_pos = cursor.read_u32()
|
||||||
@@ -73,36 +134,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
|
|||||||
|
|
||||||
payloads: list[ResultPayload] = []
|
payloads: list[ResultPayload] = []
|
||||||
for _ in range(payload_count):
|
for _ in range(payload_count):
|
||||||
kind = cursor.read_u8()
|
payloads.append(read_payload(cursor))
|
||||||
name_size = cursor.read_u16()
|
|
||||||
name = cursor.read_bytes(name_size).decode("utf-8")
|
|
||||||
|
|
||||||
if kind == 1:
|
|
||||||
point_count = cursor.read_u32()
|
|
||||||
freq = np.frombuffer(cursor.read_bytes(point_count * 4), dtype="<f4").astype(np.float32, copy=False)
|
|
||||||
interleaved = np.frombuffer(cursor.read_bytes(point_count * 8), dtype="<f4")
|
|
||||||
trace = (interleaved[0::2] + 1j * interleaved[1::2]).astype(np.complex64, copy=False)
|
|
||||||
payloads.append(
|
|
||||||
ResultPayload(
|
|
||||||
processing_name=name,
|
|
||||||
kind=kind,
|
|
||||||
frequency_hz=freq,
|
|
||||||
trace=trace,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif kind == 2:
|
|
||||||
scalar_value = cursor.read_f32()
|
|
||||||
payloads.append(
|
|
||||||
ResultPayload(
|
|
||||||
processing_name=name,
|
|
||||||
kind=kind,
|
|
||||||
frequency_hz=np.array([], dtype=np.float32),
|
|
||||||
trace=np.array([], dtype=np.complex64),
|
|
||||||
scalar_value=scalar_value,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unsupported result payload kind: {kind}")
|
|
||||||
|
|
||||||
blocks.append(
|
blocks.append(
|
||||||
ResultBlock(
|
ResultBlock(
|
||||||
@@ -111,4 +143,9 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return ResultCollection(collection_id=collection_id, monotonic_ns=monotonic_ns, blocks=blocks)
|
return ResultCollection(
|
||||||
|
collection_id=collection_id,
|
||||||
|
monotonic_ns=monotonic_ns,
|
||||||
|
collection_payloads=collection_payloads,
|
||||||
|
blocks=blocks,
|
||||||
|
)
|
||||||
|
|||||||
@@ -28,13 +28,13 @@
|
|||||||
"port2": {
|
"port2": {
|
||||||
"name": "port2",
|
"name": "port2",
|
||||||
"driver_mode": "mock",
|
"driver_mode": "mock",
|
||||||
"driver": "hmc349a",
|
"driver": "h7992",
|
||||||
"radar_port": 2,
|
"radar_port": 2,
|
||||||
"positions": 2,
|
"positions": 4,
|
||||||
"default_position": 0,
|
"default_position": 0,
|
||||||
"gpio_chip": "/dev/gpiochip0",
|
"gpio_chip": "/dev/gpiochip0",
|
||||||
"pin_a": 22,
|
"pin_a": 22,
|
||||||
"pin_b": -1,
|
"pin_b": 23,
|
||||||
"invert_logic": false
|
"invert_logic": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -53,20 +53,12 @@
|
|||||||
"output": 0
|
"output": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"input": 0,
|
"input": 2,
|
||||||
"output": 1
|
"output": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"input": 1,
|
"input": 3,
|
||||||
"output": 1
|
"output": 0
|
||||||
},
|
|
||||||
{
|
|
||||||
"input": 0,
|
|
||||||
"output": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"input": 1,
|
|
||||||
"output": 2
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"input": 0,
|
"input": 0,
|
||||||
@@ -75,6 +67,14 @@
|
|||||||
{
|
{
|
||||||
"input": 1,
|
"input": 1,
|
||||||
"output": 3
|
"output": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 2,
|
||||||
|
"output": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 3,
|
||||||
|
"output": 3
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -84,6 +84,38 @@
|
|||||||
"calibration_bundle_path": "/home/europa/Documents/radar_system/python_app/runtime/calibration_bundle.bin",
|
"calibration_bundle_path": "/home/europa/Documents/radar_system/python_app/runtime/calibration_bundle.bin",
|
||||||
"reference_bundle_path": "/home/europa/Documents/radar_system/python_app/runtime/reference_bundle.bin"
|
"reference_bundle_path": "/home/europa/Documents/radar_system/python_app/runtime/reference_bundle.bin"
|
||||||
},
|
},
|
||||||
|
"gpr": {
|
||||||
|
"mode": "point",
|
||||||
|
"relative_permittivity": 1.0,
|
||||||
|
"tx_geometry": [
|
||||||
|
{
|
||||||
|
"output_pos": 0,
|
||||||
|
"x_m": 0.905
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"output_pos": 3,
|
||||||
|
"x_m": -0.905
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rx_geometry": [
|
||||||
|
{
|
||||||
|
"input_pos": 0,
|
||||||
|
"x_m": -0.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 1,
|
||||||
|
"x_m": 0.485
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 2,
|
||||||
|
"x_m": -0.49
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 3,
|
||||||
|
"x_m": 0.185
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"rings": {
|
"rings": {
|
||||||
"raw": {
|
"raw": {
|
||||||
"name": "/radar_raw_smoke_1703912_791574940686872",
|
"name": "/radar_raw_smoke_1703912_791574940686872",
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
"""Convert manual LibreVNA S11/S21 CSV captures in prog_libre to vna history JSON."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def _real_imag_keys(trace_prefix: str) -> tuple[str, str]:
|
||||||
|
return f"{trace_prefix}_Real", f"{trace_prefix}_Imaginary"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_complex_trace(csv_path: Path, trace_prefix: str) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
real_key, imag_key = _real_imag_keys(trace_prefix)
|
||||||
|
frequencies: list[float] = []
|
||||||
|
values: list[complex] = []
|
||||||
|
|
||||||
|
with csv_path.open(encoding="utf-8", newline="") as handle:
|
||||||
|
reader = csv.DictReader(handle)
|
||||||
|
for row in reader:
|
||||||
|
frequencies.append(float(row["Frequency"]))
|
||||||
|
values.append(complex(float(row[real_key]), float(row[imag_key])))
|
||||||
|
|
||||||
|
frequency_hz = np.asarray(frequencies, dtype=np.float64)
|
||||||
|
trace = np.asarray(values, dtype=np.complex128)
|
||||||
|
if frequency_hz.size == 0 or trace.size == 0:
|
||||||
|
raise ValueError(f"CSV has no points: {csv_path}")
|
||||||
|
if frequency_hz.shape != trace.shape:
|
||||||
|
raise ValueError(f"Frequency/trace size mismatch: {csv_path}")
|
||||||
|
return frequency_hz, trace
|
||||||
|
|
||||||
|
|
||||||
|
def _solve_one_port_osl(
|
||||||
|
open_trace: np.ndarray,
|
||||||
|
short_trace: np.ndarray,
|
||||||
|
load_trace: np.ndarray,
|
||||||
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||||
|
"""Solve ideal OSL one-port calibration coefficients."""
|
||||||
|
directivity = load_trace
|
||||||
|
open_delta = open_trace - directivity
|
||||||
|
short_delta = short_trace - directivity
|
||||||
|
denom = open_delta - short_delta
|
||||||
|
|
||||||
|
source_match = np.zeros_like(directivity)
|
||||||
|
reflection_tracking = np.ones_like(directivity)
|
||||||
|
|
||||||
|
stable_mask = np.abs(denom) > 1e-18
|
||||||
|
source_match[stable_mask] = (open_delta[stable_mask] + short_delta[stable_mask]) / denom[stable_mask]
|
||||||
|
reflection_tracking[stable_mask] = open_delta[stable_mask] * (1.0 - source_match[stable_mask])
|
||||||
|
return directivity, source_match, reflection_tracking
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_one_port_osl(
|
||||||
|
measured_trace: np.ndarray,
|
||||||
|
directivity: np.ndarray,
|
||||||
|
source_match: np.ndarray,
|
||||||
|
reflection_tracking: np.ndarray,
|
||||||
|
) -> np.ndarray:
|
||||||
|
numerator = measured_trace - directivity
|
||||||
|
denominator = reflection_tracking + (source_match * numerator)
|
||||||
|
|
||||||
|
corrected = np.array(numerator, copy=True)
|
||||||
|
stable_mask = np.abs(denominator) > 1e-18
|
||||||
|
corrected[stable_mask] = numerator[stable_mask] / denominator[stable_mask]
|
||||||
|
return corrected
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_through_calibration(measured_trace: np.ndarray, through_trace: np.ndarray) -> np.ndarray:
|
||||||
|
corrected = np.array(measured_trace, copy=True)
|
||||||
|
stable_mask = np.abs(through_trace) > 1e-18
|
||||||
|
corrected[stable_mask] = measured_trace[stable_mask] / through_trace[stable_mask]
|
||||||
|
return corrected
|
||||||
|
|
||||||
|
|
||||||
|
def _complex_to_points(values: np.ndarray) -> list[list[float]]:
|
||||||
|
return [[float(value.real), float(value.imag)] for value in values]
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_file_sort_key(csv_path: Path) -> tuple[int, str]:
|
||||||
|
stem = csv_path.stem
|
||||||
|
return (int(stem), stem) if stem.isdigit() else (10**9, stem)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_scan_series(folder: Path, trace_prefix: str) -> tuple[np.ndarray, list[tuple[str, np.ndarray]]]:
|
||||||
|
scan_paths = [
|
||||||
|
path
|
||||||
|
for path in sorted(folder.glob("*.csv"), key=_scan_file_sort_key)
|
||||||
|
if path.stem.isdigit()
|
||||||
|
]
|
||||||
|
if not scan_paths:
|
||||||
|
raise FileNotFoundError(f"No numbered scan CSV files found in {folder}")
|
||||||
|
|
||||||
|
base_frequency_hz: np.ndarray | None = None
|
||||||
|
scans: list[tuple[str, np.ndarray]] = []
|
||||||
|
for path in scan_paths:
|
||||||
|
frequency_hz, trace = _load_complex_trace(path, trace_prefix)
|
||||||
|
if base_frequency_hz is None:
|
||||||
|
base_frequency_hz = frequency_hz
|
||||||
|
elif not np.allclose(base_frequency_hz, frequency_hz, rtol=0.0, atol=1e-6):
|
||||||
|
raise ValueError(f"Frequency axis mismatch in {path}")
|
||||||
|
scans.append((path.name, trace))
|
||||||
|
|
||||||
|
assert base_frequency_hz is not None
|
||||||
|
return base_frequency_hz, scans
|
||||||
|
|
||||||
|
|
||||||
|
def _require_matching_frequency_axis(label: str, left: np.ndarray, right: np.ndarray) -> None:
|
||||||
|
if not np.allclose(left, right, rtol=0.0, atol=1e-6):
|
||||||
|
raise ValueError(f"{label} frequency axes do not match")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_history_payload(
|
||||||
|
*,
|
||||||
|
source_dir: Path,
|
||||||
|
mode: str,
|
||||||
|
frequency_hz: np.ndarray,
|
||||||
|
sweep_scans: list[tuple[str, np.ndarray]],
|
||||||
|
calibrated_scans: list[tuple[str, np.ndarray]],
|
||||||
|
reference_trace: np.ndarray,
|
||||||
|
primary_stage: str,
|
||||||
|
raw_record_count: int,
|
||||||
|
preprocessed_record_count: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if len(sweep_scans) != len(calibrated_scans):
|
||||||
|
raise ValueError("Sweep/calibrated scan counts do not match")
|
||||||
|
|
||||||
|
sweep_history: list[dict[str, Any]] = []
|
||||||
|
reference_points = _complex_to_points(reference_trace)
|
||||||
|
for index, ((scan_name, sweep_trace), (cal_name, calibrated_trace)) in enumerate(
|
||||||
|
zip(sweep_scans, calibrated_scans, strict=True)
|
||||||
|
):
|
||||||
|
if scan_name != cal_name:
|
||||||
|
raise ValueError(f"Scan ordering mismatch: {scan_name} vs {cal_name}")
|
||||||
|
|
||||||
|
sweep_history.append(
|
||||||
|
{
|
||||||
|
"timestamp": float(index),
|
||||||
|
"sweep_points": _complex_to_points(sweep_trace),
|
||||||
|
"calibrated_points": _complex_to_points(calibrated_trace),
|
||||||
|
"reference_points": reference_points,
|
||||||
|
"vna_config": {
|
||||||
|
"mode": mode,
|
||||||
|
"start_freq": float(frequency_hz[0]),
|
||||||
|
"stop_freq": float(frequency_hz[-1]),
|
||||||
|
"points": int(frequency_hz.size),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"format": "vna-system-history-v1",
|
||||||
|
"converter": "python_app/scripts/convert_prog_libre_manual_to_vna_history.py",
|
||||||
|
"converted_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"source_snapshot_dir": str(source_dir.resolve()),
|
||||||
|
"input_index": 0,
|
||||||
|
"output_index": 0,
|
||||||
|
"primary_stage": primary_stage,
|
||||||
|
"raw_record_count": int(raw_record_count),
|
||||||
|
"preprocessed_record_count": int(preprocessed_record_count),
|
||||||
|
"sweep_history": sweep_history,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_payload(output_path: Path, payload: dict[str, Any]) -> None:
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _rmse(left: np.ndarray, right: np.ndarray) -> float:
|
||||||
|
return float(np.sqrt(np.mean(np.abs(left - right) ** 2)))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Convert manual LibreVNA S11/S21 CSV captures in prog_libre to vna history JSON.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--calibration-dir",
|
||||||
|
type=Path,
|
||||||
|
default=Path("prog_libre/calibration"),
|
||||||
|
help="Directory with calibration CSV files.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--raw-dir",
|
||||||
|
type=Path,
|
||||||
|
default=Path("prog_libre/1-6000mhz_no-calibrated_libre"),
|
||||||
|
help="Directory with uncalibrated scan CSV files and ref.csv.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--calibrated-dir",
|
||||||
|
type=Path,
|
||||||
|
default=Path("prog_libre/1-6000mhz_calibrated_libre"),
|
||||||
|
help="Directory with already calibrated scan CSV files and ref.csv.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--raw-output",
|
||||||
|
"--s11-raw-output",
|
||||||
|
dest="s11_raw_output",
|
||||||
|
type=Path,
|
||||||
|
default=Path("prog_libre/1-6000mhz_no-calibrated_libre_s11_osl_p1_vna_bscan_history.json"),
|
||||||
|
help="Output JSON for uncalibrated S11 scans after applying OSL calibration.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--calibrated-output",
|
||||||
|
"--s11-calibrated-output",
|
||||||
|
dest="s11_calibrated_output",
|
||||||
|
type=Path,
|
||||||
|
default=Path("prog_libre/1-6000mhz_calibrated_libre_s11_passthrough_vna_bscan_history.json"),
|
||||||
|
help="Output JSON for already calibrated S11 scans without extra calibration.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--s21-raw-output",
|
||||||
|
dest="s21_raw_output",
|
||||||
|
type=Path,
|
||||||
|
default=Path("prog_libre/1-6000mhz_no-calibrated_libre_s21_through_vna_bscan_history.json"),
|
||||||
|
help="Output JSON for uncalibrated S21 scans after applying through calibration.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--s21-calibrated-output",
|
||||||
|
dest="s21_calibrated_output",
|
||||||
|
type=Path,
|
||||||
|
default=Path("prog_libre/1-6000mhz_calibrated_libre_s21_passthrough_vna_bscan_history.json"),
|
||||||
|
help="Output JSON for already calibrated S21 scans without extra calibration.",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = _build_parser().parse_args()
|
||||||
|
|
||||||
|
calibration_dir = args.calibration_dir.expanduser().resolve()
|
||||||
|
raw_dir = args.raw_dir.expanduser().resolve()
|
||||||
|
calibrated_dir = args.calibrated_dir.expanduser().resolve()
|
||||||
|
|
||||||
|
s11_raw_output = args.s11_raw_output.expanduser().resolve()
|
||||||
|
s11_calibrated_output = args.s11_calibrated_output.expanduser().resolve()
|
||||||
|
s21_raw_output = args.s21_raw_output.expanduser().resolve()
|
||||||
|
s21_calibrated_output = args.s21_calibrated_output.expanduser().resolve()
|
||||||
|
|
||||||
|
s11_cal_frequency_hz, open_trace = _load_complex_trace(calibration_dir / "open_rfc18_p1.csv", "S11")
|
||||||
|
short_frequency_hz, short_trace = _load_complex_trace(calibration_dir / "short_rfc18_p1.csv", "S11")
|
||||||
|
load_frequency_hz, load_trace = _load_complex_trace(calibration_dir / "load_rfc18_p1.csv", "S11")
|
||||||
|
_require_matching_frequency_axis("S11 calibration", s11_cal_frequency_hz, short_frequency_hz)
|
||||||
|
_require_matching_frequency_axis("S11 calibration", s11_cal_frequency_hz, load_frequency_hz)
|
||||||
|
|
||||||
|
directivity, source_match, reflection_tracking = _solve_one_port_osl(open_trace, short_trace, load_trace)
|
||||||
|
|
||||||
|
s11_raw_frequency_hz, s11_raw_scans = _load_scan_series(raw_dir, "S11")
|
||||||
|
s11_calibrated_frequency_hz, s11_passthrough_scans = _load_scan_series(calibrated_dir, "S11")
|
||||||
|
_require_matching_frequency_axis("S11 raw vs calibration", s11_raw_frequency_hz, s11_cal_frequency_hz)
|
||||||
|
_require_matching_frequency_axis("S11 calibrated vs calibration", s11_calibrated_frequency_hz, s11_cal_frequency_hz)
|
||||||
|
|
||||||
|
s11_raw_reference_frequency_hz, s11_raw_reference = _load_complex_trace(raw_dir / "ref.csv", "S11")
|
||||||
|
s11_calibrated_reference_frequency_hz, s11_calibrated_reference = _load_complex_trace(calibrated_dir / "ref.csv", "S11")
|
||||||
|
_require_matching_frequency_axis("S11 raw reference vs calibration", s11_raw_reference_frequency_hz, s11_cal_frequency_hz)
|
||||||
|
_require_matching_frequency_axis(
|
||||||
|
"S11 calibrated reference vs calibration",
|
||||||
|
s11_calibrated_reference_frequency_hz,
|
||||||
|
s11_cal_frequency_hz,
|
||||||
|
)
|
||||||
|
|
||||||
|
s11_corrected_scans = [
|
||||||
|
(
|
||||||
|
scan_name,
|
||||||
|
_apply_one_port_osl(trace, directivity, source_match, reflection_tracking),
|
||||||
|
)
|
||||||
|
for scan_name, trace in s11_raw_scans
|
||||||
|
]
|
||||||
|
s11_corrected_reference = _apply_one_port_osl(s11_raw_reference, directivity, source_match, reflection_tracking)
|
||||||
|
|
||||||
|
s11_raw_payload = _build_history_payload(
|
||||||
|
source_dir=raw_dir,
|
||||||
|
mode="s11",
|
||||||
|
frequency_hz=s11_raw_frequency_hz,
|
||||||
|
sweep_scans=s11_raw_scans,
|
||||||
|
calibrated_scans=s11_corrected_scans,
|
||||||
|
reference_trace=s11_corrected_reference,
|
||||||
|
primary_stage="raw",
|
||||||
|
raw_record_count=len(s11_raw_scans),
|
||||||
|
preprocessed_record_count=len(s11_corrected_scans),
|
||||||
|
)
|
||||||
|
s11_calibrated_payload = _build_history_payload(
|
||||||
|
source_dir=calibrated_dir,
|
||||||
|
mode="s11",
|
||||||
|
frequency_hz=s11_calibrated_frequency_hz,
|
||||||
|
sweep_scans=s11_passthrough_scans,
|
||||||
|
calibrated_scans=s11_passthrough_scans,
|
||||||
|
reference_trace=s11_calibrated_reference,
|
||||||
|
primary_stage="preprocessed",
|
||||||
|
raw_record_count=0,
|
||||||
|
preprocessed_record_count=len(s11_passthrough_scans),
|
||||||
|
)
|
||||||
|
|
||||||
|
s21_cal_frequency_hz, through_trace = _load_complex_trace(calibration_dir / "through21_rfc18.csv", "S21")
|
||||||
|
|
||||||
|
s21_raw_frequency_hz, s21_raw_scans = _load_scan_series(raw_dir, "S21")
|
||||||
|
s21_calibrated_frequency_hz, s21_passthrough_scans = _load_scan_series(calibrated_dir, "S21")
|
||||||
|
_require_matching_frequency_axis("S21 raw vs calibration", s21_raw_frequency_hz, s21_cal_frequency_hz)
|
||||||
|
_require_matching_frequency_axis("S21 calibrated vs calibration", s21_calibrated_frequency_hz, s21_cal_frequency_hz)
|
||||||
|
|
||||||
|
s21_raw_reference_frequency_hz, s21_raw_reference = _load_complex_trace(raw_dir / "ref.csv", "S21")
|
||||||
|
s21_calibrated_reference_frequency_hz, s21_calibrated_reference = _load_complex_trace(calibrated_dir / "ref.csv", "S21")
|
||||||
|
_require_matching_frequency_axis("S21 raw reference vs calibration", s21_raw_reference_frequency_hz, s21_cal_frequency_hz)
|
||||||
|
_require_matching_frequency_axis(
|
||||||
|
"S21 calibrated reference vs calibration",
|
||||||
|
s21_calibrated_reference_frequency_hz,
|
||||||
|
s21_cal_frequency_hz,
|
||||||
|
)
|
||||||
|
|
||||||
|
s21_corrected_scans = [
|
||||||
|
(
|
||||||
|
scan_name,
|
||||||
|
_apply_through_calibration(trace, through_trace),
|
||||||
|
)
|
||||||
|
for scan_name, trace in s21_raw_scans
|
||||||
|
]
|
||||||
|
s21_corrected_reference = _apply_through_calibration(s21_raw_reference, through_trace)
|
||||||
|
|
||||||
|
s21_raw_payload = _build_history_payload(
|
||||||
|
source_dir=raw_dir,
|
||||||
|
mode="s21",
|
||||||
|
frequency_hz=s21_raw_frequency_hz,
|
||||||
|
sweep_scans=s21_raw_scans,
|
||||||
|
calibrated_scans=s21_corrected_scans,
|
||||||
|
reference_trace=s21_corrected_reference,
|
||||||
|
primary_stage="raw",
|
||||||
|
raw_record_count=len(s21_raw_scans),
|
||||||
|
preprocessed_record_count=len(s21_corrected_scans),
|
||||||
|
)
|
||||||
|
s21_calibrated_payload = _build_history_payload(
|
||||||
|
source_dir=calibrated_dir,
|
||||||
|
mode="s21",
|
||||||
|
frequency_hz=s21_calibrated_frequency_hz,
|
||||||
|
sweep_scans=s21_passthrough_scans,
|
||||||
|
calibrated_scans=s21_passthrough_scans,
|
||||||
|
reference_trace=s21_calibrated_reference,
|
||||||
|
primary_stage="preprocessed",
|
||||||
|
raw_record_count=0,
|
||||||
|
preprocessed_record_count=len(s21_passthrough_scans),
|
||||||
|
)
|
||||||
|
|
||||||
|
_write_payload(s11_raw_output, s11_raw_payload)
|
||||||
|
_write_payload(s11_calibrated_output, s11_calibrated_payload)
|
||||||
|
_write_payload(s21_raw_output, s21_raw_payload)
|
||||||
|
_write_payload(s21_calibrated_output, s21_calibrated_payload)
|
||||||
|
|
||||||
|
s11_rmse_values = [
|
||||||
|
_rmse(corrected_trace, passthrough_trace)
|
||||||
|
for (_, corrected_trace), (_, passthrough_trace) in zip(s11_corrected_scans, s11_passthrough_scans, strict=True)
|
||||||
|
]
|
||||||
|
s21_rmse_values = [
|
||||||
|
_rmse(corrected_trace, passthrough_trace)
|
||||||
|
for (_, corrected_trace), (_, passthrough_trace) in zip(s21_corrected_scans, s21_passthrough_scans, strict=True)
|
||||||
|
]
|
||||||
|
s11_reference_rmse = _rmse(s11_corrected_reference, s11_calibrated_reference)
|
||||||
|
s21_reference_rmse = _rmse(s21_corrected_reference, s21_calibrated_reference)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Converted manual prog_libre captures to vna history JSON:\n"
|
||||||
|
f" S11 raw output: {s11_raw_output}\n"
|
||||||
|
f" S11 calibrated output: {s11_calibrated_output}\n"
|
||||||
|
f" S21 raw output: {s21_raw_output}\n"
|
||||||
|
f" S21 calibrated output: {s21_calibrated_output}\n"
|
||||||
|
f" sweep count: {len(s11_raw_scans)}\n"
|
||||||
|
f" points per sweep: {s11_raw_frequency_hz.size}\n"
|
||||||
|
f" S11 calibration: p1 ideal OSL\n"
|
||||||
|
f" S11 mean sweep RMSE vs provided calibrated folder: {float(np.mean(s11_rmse_values)):.6f}\n"
|
||||||
|
f" S11 max sweep RMSE vs provided calibrated folder: {float(np.max(s11_rmse_values)):.6f}\n"
|
||||||
|
f" S11 reference RMSE vs provided calibrated ref: {s11_reference_rmse:.6f}\n"
|
||||||
|
f" S21 calibration: through21 complex division\n"
|
||||||
|
f" S21 mean sweep RMSE vs provided calibrated folder: {float(np.mean(s21_rmse_values)):.6f}\n"
|
||||||
|
f" S21 max sweep RMSE vs provided calibrated folder: {float(np.max(s21_rmse_values)):.6f}\n"
|
||||||
|
f" S21 reference RMSE vs provided calibrated ref: {s21_reference_rmse:.6f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -39,39 +39,79 @@ def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes
|
|||||||
|
|
||||||
def serialize_result_collection(collection: ResultCollection) -> bytes:
|
def serialize_result_collection(collection: ResultCollection) -> bytes:
|
||||||
"""Serialize one processed collection with result blocks/payloads."""
|
"""Serialize one processed collection with result blocks/payloads."""
|
||||||
|
|
||||||
|
def serialize_payload(buffer: bytearray, payload) -> None:
|
||||||
|
"""Append one payload in ring-compatible result format."""
|
||||||
|
name_bytes = payload.processing_name.encode("utf-8")
|
||||||
|
if len(name_bytes) > 0xFFFF:
|
||||||
|
raise ValueError("processing_name is too long")
|
||||||
|
|
||||||
|
buffer.extend(struct.pack("<BH", payload.kind, len(name_bytes)))
|
||||||
|
buffer.extend(name_bytes)
|
||||||
|
|
||||||
|
if payload.kind == 1:
|
||||||
|
freq = np.asarray(payload.frequency_hz, dtype=np.float32)
|
||||||
|
trace = np.asarray(payload.trace, dtype=np.complex64)
|
||||||
|
if freq.size != trace.size:
|
||||||
|
raise ValueError("Result trace frequency and values sizes must match")
|
||||||
|
|
||||||
|
buffer.extend(struct.pack("<I", int(freq.size)))
|
||||||
|
buffer.extend(freq.astype("<f4", copy=False).tobytes())
|
||||||
|
|
||||||
|
interleaved = np.empty(freq.size * 2, dtype="<f4")
|
||||||
|
interleaved[0::2] = trace.real.astype("<f4", copy=False)
|
||||||
|
interleaved[1::2] = trace.imag.astype("<f4", copy=False)
|
||||||
|
buffer.extend(interleaved.tobytes())
|
||||||
|
return
|
||||||
|
|
||||||
|
if payload.kind == 2:
|
||||||
|
buffer.extend(struct.pack("<f", float(payload.scalar_value)))
|
||||||
|
return
|
||||||
|
|
||||||
|
if payload.kind == 3:
|
||||||
|
image_x_axis = np.asarray(payload.image_x_axis, dtype=np.float32)
|
||||||
|
image_y_axis = np.asarray(payload.image_y_axis, dtype=np.float32)
|
||||||
|
image = np.asarray(payload.image, dtype=np.float32)
|
||||||
|
if image.ndim != 2:
|
||||||
|
raise ValueError("Result image payload must be a 2D matrix")
|
||||||
|
if image.shape != (image_y_axis.size, image_x_axis.size):
|
||||||
|
raise ValueError("Result image axis sizes must match image matrix shape")
|
||||||
|
buffer.extend(struct.pack("<II", int(image_x_axis.size), int(image_y_axis.size)))
|
||||||
|
buffer.extend(image_x_axis.astype("<f4", copy=False).tobytes())
|
||||||
|
buffer.extend(image_y_axis.astype("<f4", copy=False).tobytes())
|
||||||
|
buffer.extend(image.astype("<f4", copy=False).ravel(order="C").tobytes())
|
||||||
|
return
|
||||||
|
|
||||||
|
if payload.kind == 4:
|
||||||
|
table = np.asarray(payload.table, dtype=np.float32)
|
||||||
|
if table.ndim != 2:
|
||||||
|
raise ValueError("Result table payload must be a 2D matrix")
|
||||||
|
buffer.extend(struct.pack("<II", int(table.shape[1]), int(table.shape[0])))
|
||||||
|
buffer.extend(table.astype("<f4", copy=False).ravel(order="C").tobytes())
|
||||||
|
return
|
||||||
|
|
||||||
|
raise ValueError(f"Unsupported payload kind: {payload.kind}")
|
||||||
|
|
||||||
buffer = bytearray()
|
buffer = bytearray()
|
||||||
buffer.extend(
|
buffer.extend(
|
||||||
struct.pack("<IQQI", RESULT_MAGIC, collection.collection_id, collection.monotonic_ns, len(collection.blocks))
|
struct.pack(
|
||||||
|
"<IQQII",
|
||||||
|
RESULT_MAGIC,
|
||||||
|
collection.collection_id,
|
||||||
|
collection.monotonic_ns,
|
||||||
|
len(collection.collection_payloads),
|
||||||
|
len(collection.blocks),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for payload in collection.collection_payloads:
|
||||||
|
serialize_payload(buffer, payload)
|
||||||
|
|
||||||
for block in collection.blocks:
|
for block in collection.blocks:
|
||||||
buffer.extend(struct.pack("<II", block.combo.input_pos, block.combo.output_pos))
|
buffer.extend(struct.pack("<II", block.combo.input_pos, block.combo.output_pos))
|
||||||
buffer.extend(struct.pack("<I", len(block.payloads)))
|
buffer.extend(struct.pack("<I", len(block.payloads)))
|
||||||
|
|
||||||
for payload in block.payloads:
|
for payload in block.payloads:
|
||||||
name_bytes = payload.processing_name.encode("utf-8")
|
serialize_payload(buffer, payload)
|
||||||
if len(name_bytes) > 0xFFFF:
|
|
||||||
raise ValueError("processing_name is too long")
|
|
||||||
|
|
||||||
buffer.extend(struct.pack("<BH", payload.kind, len(name_bytes)))
|
|
||||||
buffer.extend(name_bytes)
|
|
||||||
|
|
||||||
if payload.kind == 1:
|
|
||||||
freq = np.asarray(payload.frequency_hz, dtype=np.float32)
|
|
||||||
trace = np.asarray(payload.trace, dtype=np.complex64)
|
|
||||||
if freq.size != trace.size:
|
|
||||||
raise ValueError("Result trace frequency and values sizes must match")
|
|
||||||
|
|
||||||
buffer.extend(struct.pack("<I", int(freq.size)))
|
|
||||||
buffer.extend(freq.astype("<f4", copy=False).tobytes())
|
|
||||||
|
|
||||||
interleaved = np.empty(freq.size * 2, dtype="<f4")
|
|
||||||
interleaved[0::2] = trace.real.astype("<f4", copy=False)
|
|
||||||
interleaved[1::2] = trace.imag.astype("<f4", copy=False)
|
|
||||||
buffer.extend(interleaved.tobytes())
|
|
||||||
elif payload.kind == 2:
|
|
||||||
buffer.extend(struct.pack("<f", float(payload.scalar_value)))
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unsupported payload kind: {payload.kind}")
|
|
||||||
|
|
||||||
return bytes(buffer)
|
return bytes(buffer)
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ def save_result_history_binary(stage_dir: Path, history: list[ResultCollection])
|
|||||||
{
|
{
|
||||||
"collection_id": collection.collection_id,
|
"collection_id": collection.collection_id,
|
||||||
"monotonic_ns": collection.monotonic_ns,
|
"monotonic_ns": collection.monotonic_ns,
|
||||||
|
"collection_payload_count": len(collection.collection_payloads),
|
||||||
"block_count": len(collection.blocks),
|
"block_count": len(collection.blocks),
|
||||||
},
|
},
|
||||||
indent=2,
|
indent=2,
|
||||||
@@ -190,6 +191,66 @@ def save_result_history_numpy(stage_dir: Path, history: list[ResultCollection])
|
|||||||
collection_dir = stage_dir / collection_dir_name(index, collection.collection_id, collection.monotonic_ns)
|
collection_dir = stage_dir / collection_dir_name(index, collection.collection_id, collection.monotonic_ns)
|
||||||
collection_dir.mkdir(parents=True, exist_ok=False)
|
collection_dir.mkdir(parents=True, exist_ok=False)
|
||||||
|
|
||||||
|
collection_payload_meta: list[dict[str, int | str | float]] = []
|
||||||
|
for payload_index, payload in enumerate(collection.collection_payloads):
|
||||||
|
safe_name = sanitize_path_component(payload.processing_name or "processor")
|
||||||
|
base_name = f"collection_{payload_index:03d}_{safe_name}_kind{payload.kind}"
|
||||||
|
if payload.kind == 1:
|
||||||
|
freq = np.asarray(payload.frequency_hz, dtype=np.float32)
|
||||||
|
trace = np.asarray(payload.trace, dtype=np.complex64)
|
||||||
|
np.save(collection_dir / f"{base_name}_freq.npy", freq)
|
||||||
|
np.save(collection_dir / f"{base_name}_trace.npy", trace)
|
||||||
|
collection_payload_meta.append(
|
||||||
|
{
|
||||||
|
"kind": int(payload.kind),
|
||||||
|
"name": payload.processing_name,
|
||||||
|
"points": int(freq.size),
|
||||||
|
"freq_file": f"{base_name}_freq.npy",
|
||||||
|
"trace_file": f"{base_name}_trace.npy",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif payload.kind == 2:
|
||||||
|
scalar = np.asarray([float(payload.scalar_value)], dtype=np.float32)
|
||||||
|
np.save(collection_dir / f"{base_name}_scalar.npy", scalar)
|
||||||
|
collection_payload_meta.append(
|
||||||
|
{
|
||||||
|
"kind": int(payload.kind),
|
||||||
|
"name": payload.processing_name,
|
||||||
|
"scalar_file": f"{base_name}_scalar.npy",
|
||||||
|
"scalar_value": float(payload.scalar_value),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif payload.kind == 3:
|
||||||
|
image_x_axis = np.asarray(payload.image_x_axis, dtype=np.float32)
|
||||||
|
image_y_axis = np.asarray(payload.image_y_axis, dtype=np.float32)
|
||||||
|
image = np.asarray(payload.image, dtype=np.float32)
|
||||||
|
np.save(collection_dir / f"{base_name}_x_axis.npy", image_x_axis)
|
||||||
|
np.save(collection_dir / f"{base_name}_y_axis.npy", image_y_axis)
|
||||||
|
np.save(collection_dir / f"{base_name}_image.npy", image)
|
||||||
|
collection_payload_meta.append(
|
||||||
|
{
|
||||||
|
"kind": int(payload.kind),
|
||||||
|
"name": payload.processing_name,
|
||||||
|
"x_points": int(image_x_axis.size),
|
||||||
|
"y_points": int(image_y_axis.size),
|
||||||
|
"x_axis_file": f"{base_name}_x_axis.npy",
|
||||||
|
"y_axis_file": f"{base_name}_y_axis.npy",
|
||||||
|
"image_file": f"{base_name}_image.npy",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif payload.kind == 4:
|
||||||
|
table = np.asarray(payload.table, dtype=np.float32)
|
||||||
|
np.save(collection_dir / f"{base_name}_table.npy", table)
|
||||||
|
collection_payload_meta.append(
|
||||||
|
{
|
||||||
|
"kind": int(payload.kind),
|
||||||
|
"name": payload.processing_name,
|
||||||
|
"rows": int(table.shape[0]) if table.ndim == 2 else 0,
|
||||||
|
"columns": int(table.shape[1]) if table.ndim == 2 else 0,
|
||||||
|
"table_file": f"{base_name}_table.npy",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
blocks_meta: list[dict[str, int | str | list[dict[str, int | str | float]]]] = []
|
blocks_meta: list[dict[str, int | str | list[dict[str, int | str | float]]]] = []
|
||||||
for block_index, block in enumerate(collection.blocks):
|
for block_index, block in enumerate(collection.blocks):
|
||||||
block_dir = collection_dir / f"block_{block_index:03d}_i{block.combo.input_pos}_o{block.combo.output_pos}"
|
block_dir = collection_dir / f"block_{block_index:03d}_i{block.combo.input_pos}_o{block.combo.output_pos}"
|
||||||
@@ -224,6 +285,36 @@ def save_result_history_numpy(stage_dir: Path, history: list[ResultCollection])
|
|||||||
"scalar_value": float(payload.scalar_value),
|
"scalar_value": float(payload.scalar_value),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
elif payload.kind == 3:
|
||||||
|
image_x_axis = np.asarray(payload.image_x_axis, dtype=np.float32)
|
||||||
|
image_y_axis = np.asarray(payload.image_y_axis, dtype=np.float32)
|
||||||
|
image = np.asarray(payload.image, dtype=np.float32)
|
||||||
|
np.save(block_dir / f"{base_name}_x_axis.npy", image_x_axis)
|
||||||
|
np.save(block_dir / f"{base_name}_y_axis.npy", image_y_axis)
|
||||||
|
np.save(block_dir / f"{base_name}_image.npy", image)
|
||||||
|
payload_meta.append(
|
||||||
|
{
|
||||||
|
"kind": int(payload.kind),
|
||||||
|
"name": payload.processing_name,
|
||||||
|
"x_points": int(image_x_axis.size),
|
||||||
|
"y_points": int(image_y_axis.size),
|
||||||
|
"x_axis_file": f"{base_name}_x_axis.npy",
|
||||||
|
"y_axis_file": f"{base_name}_y_axis.npy",
|
||||||
|
"image_file": f"{base_name}_image.npy",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif payload.kind == 4:
|
||||||
|
table = np.asarray(payload.table, dtype=np.float32)
|
||||||
|
np.save(block_dir / f"{base_name}_table.npy", table)
|
||||||
|
payload_meta.append(
|
||||||
|
{
|
||||||
|
"kind": int(payload.kind),
|
||||||
|
"name": payload.processing_name,
|
||||||
|
"rows": int(table.shape[0]) if table.ndim == 2 else 0,
|
||||||
|
"columns": int(table.shape[1]) if table.ndim == 2 else 0,
|
||||||
|
"table_file": f"{base_name}_table.npy",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
blocks_meta.append(
|
blocks_meta.append(
|
||||||
{
|
{
|
||||||
@@ -240,6 +331,8 @@ def save_result_history_numpy(stage_dir: Path, history: list[ResultCollection])
|
|||||||
{
|
{
|
||||||
"collection_id": int(collection.collection_id),
|
"collection_id": int(collection.collection_id),
|
||||||
"monotonic_ns": int(collection.monotonic_ns),
|
"monotonic_ns": int(collection.monotonic_ns),
|
||||||
|
"collection_payload_count": len(collection.collection_payloads),
|
||||||
|
"collection_payloads": collection_payload_meta,
|
||||||
"block_count": len(collection.blocks),
|
"block_count": len(collection.blocks),
|
||||||
"blocks": blocks_meta,
|
"blocks": blocks_meta,
|
||||||
},
|
},
|
||||||
|
|||||||
+42
-4
@@ -28,13 +28,13 @@
|
|||||||
"port2": {
|
"port2": {
|
||||||
"name": "port2",
|
"name": "port2",
|
||||||
"driver_mode": "native",
|
"driver_mode": "native",
|
||||||
"driver": "hmc349a",
|
"driver": "h7992",
|
||||||
"radar_port": 2,
|
"radar_port": 2,
|
||||||
"positions": 2,
|
"positions": 4,
|
||||||
"default_position": 0,
|
"default_position": 0,
|
||||||
"gpio_chip": "/dev/gpiochip0",
|
"gpio_chip": "/dev/gpiochip0",
|
||||||
"pin_a": 22,
|
"pin_a": 22,
|
||||||
"pin_b": -1,
|
"pin_b": 23,
|
||||||
"invert_logic": false
|
"invert_logic": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -45,7 +45,13 @@
|
|||||||
"processing_live_config_path": "python_app/runtime/processing_live.json",
|
"processing_live_config_path": "python_app/runtime/processing_live.json",
|
||||||
"combos": [
|
"combos": [
|
||||||
{"input": 0, "output": 0},
|
{"input": 0, "output": 0},
|
||||||
{"input": 1, "output": 0}
|
{"input": 1, "output": 0},
|
||||||
|
{"input": 2, "output": 0},
|
||||||
|
{"input": 3, "output": 0},
|
||||||
|
{"input": 0, "output": 3},
|
||||||
|
{"input": 1, "output": 3},
|
||||||
|
{"input": 2, "output": 3},
|
||||||
|
{"input": 3, "output": 3}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"preprocess": {
|
"preprocess": {
|
||||||
@@ -54,6 +60,38 @@
|
|||||||
"calibration_bundle_path": "python_app/runtime/calibration_bundle.bin",
|
"calibration_bundle_path": "python_app/runtime/calibration_bundle.bin",
|
||||||
"reference_bundle_path": "python_app/runtime/reference_bundle.bin"
|
"reference_bundle_path": "python_app/runtime/reference_bundle.bin"
|
||||||
},
|
},
|
||||||
|
"gpr": {
|
||||||
|
"mode": "point",
|
||||||
|
"relative_permittivity": 1.0,
|
||||||
|
"tx_geometry": [
|
||||||
|
{
|
||||||
|
"output_pos": 0,
|
||||||
|
"x_m": 0.905
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"output_pos": 3,
|
||||||
|
"x_m": -0.905
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rx_geometry": [
|
||||||
|
{
|
||||||
|
"input_pos": 0,
|
||||||
|
"x_m": -0.18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 1,
|
||||||
|
"x_m": 0.485
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 2,
|
||||||
|
"x_m": -0.49
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 3,
|
||||||
|
"x_m": 0.185
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"rings": {
|
"rings": {
|
||||||
"raw": {
|
"raw": {
|
||||||
"name": "/radar_raw",
|
"name": "/radar_raw",
|
||||||
|
|||||||
Reference in New Issue
Block a user