added GPR

This commit is contained in:
Ayzen
2026-03-19 19:29:08 +03:00
parent bdefe3f581
commit 9581730e41
39 changed files with 3830 additions and 201 deletions
+653
View File
@@ -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()
+1
View File
@@ -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
+12
View File
@@ -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
+41 -7
View File
@@ -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}"
) )
+22 -2
View File
@@ -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)
+58 -2
View File
@@ -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,
+6
View File
@@ -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",
+27
View File
@@ -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
+32 -1
View File
@@ -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),
} }
+68 -31
View File
@@ -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,
)
+47 -15
View File
@@ -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()
+65 -25
View File
@@ -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)
+93
View File
@@ -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
View File
@@ -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",