diff --git a/.gitignore b/.gitignore
index deb8359..1a911db 100644
--- a/.gitignore
+++ b/.gitignore
@@ -245,9 +245,10 @@ __marimo__/
python_app/runtime
SHARE_INTERNET_TO_PI.md
-# Локальная обвязка Claude Code: инструкции, хуки, скилы, конфиги инструментов.
-# В репозиторий не идёт.
+# Локальная обвязка Claude Code и Codex: инструкции, хуки, скилы, конфиги инструментов.
+# В репозиторий не идёт. AGENTS.md — симлинк на CLAUDE.md для Codex.
CLAUDE.md
+AGENTS.md
.claude/
.graphifyignore
pyrightconfig.json
diff --git a/Horns_motion_3libre.py b/Horns_motion_3libre.py
deleted file mode 100644
index 23233b6..0000000
--- a/Horns_motion_3libre.py
+++ /dev/null
@@ -1,1326 +0,0 @@
-"""
-MIMO GPR — coherent time-domain BackProjection
-=================================================
-
-Эта ячейка полностью независима от верхнего эллипсного алгоритма:
- 1. загружает те же S21-данные;
- 2. строит oversampled A-сканы через тот же частотный сдвиг перед IFFT;
- 3. для каждой точки (x,z) вычисляет tau_ij = (Rtx + Rrx) / v;
- 4. интерполирует комплексный A-скан в этой задержке;
- 5. когерентно суммирует комплексные вклады всех Tx/Rx-пар с компенсацией geo·pattern.
-
-Это coherent BP: суммируются комплексные h_ij(tau), затем строится |sum h_ij|.
-"""
-
-import builtins
-import json
-import numpy as np
-import matplotlib.pyplot as plt
-from scipy.ndimage import gaussian_filter, label
-from pathlib import Path
-from dataclasses import dataclass, field
-from typing import Dict, List, Tuple
-
-_print_raw = builtins.print
-PRINT_DIAGNOSTICS = False
-
-
-def print(*args, **kwargs):
- if PRINT_DIAGNOSTICS:
- _print_raw(*args, **kwargs)
-
-
-# ══════════════════════════════════════════════════════
-# 0.1 ИЗМЕНЯЕМЫЕ ПАРАМЕТРЫ
-# ══════════════════════════════════════════════════════
-INPUT_IDX = [0, 1, 2, 3]
-OUTPUT_IDX = [0, 1]
-
-# Частотный диапазон и глубинный gate.
-F_START = 38 * 1e8
-F_STOP = 6.0 * 1e9
-MIN_DEPTH = 3.0
-MAX_DEPTH = 12.0
-
-# Движение радара во время кадра.
-# Подтвержденная схема измерения: Tx0(f0), Tx1(f0), Tx0(f1), Tx1(f1), ...
-# 'int_minus' — полная intra-frequency correction к центру interleaved sweep;
-# 'int_focus' — focus-like correction без чистого Z-сдвига.
-SPEED_M_S = 1.75 # скорость в м/с
-LOOK_ANGLE_DEG = 7.5 # угол наклона радара, град
-TX_SWEEP_TIME_S = 0.0714 # Половинное время свипа, с
-MOTION_CORRECTION_MODE = 'int_minus' # 'int_minus' или 'int_focus'
-
-# Данные и вычитание среднего фона.
-BG_SUBTRACT = True
-DATA_PATH = Path('/Users/ivan_root/Downloads/Telegram_dwnld/20260608_moving/20260608_3.5-6_751_10k_-3p_2nd/preprocessed/0015_id16_ns8216344309213')
-BG_PATH = DATA_PATH.parent
-
-# Убрать паразитные боковые лепестки с 2D карты.
-BP_REMOVE_SIDELOBE_OBJECTS = True # True: убрать SL-кандидаты из финальной таблицы и разметки
-BP_MAX_DETECTED_OBJECTS_TO_DRAW = 8 # N: если найдено больше объектов, цели на карте не рисуются
-BP_DRAW_TOP_M_OBJECTS = 4 # M: если найдено <= N, рисуются только первые M по текущему score
-BP_OBJECT_MIN_FRAC = 0.7 # остановка: пик ниже этой доли от глобального максимума
-
-# Компенсация затухания разделена на 2 части:
-# range: геометрическое расхождение 1/(Rtx*Rrx)
-# angle: диаграмма направленности cos_tx^2 * cos_rx^2
-COMP_RANGE_POWER = 0.1
-
-# Метрика для ранжирования целей.
-# 'peak' — порядок по максимумам coherent BP;
-# 'combined' — coherent peak + coherence factor + prominence + contrast.
-BP_SCORE_MODE = 'combined'
-
-# ══════════════════════════════════════════════════════
-# 0.2 КОНФИГИ АНТЕНН И ДВИЖЕНИЯ
-# ══════════════════════════════════════════════════════
-
-# Физические координаты антенн по их реальным индексам, [м].
-# Формат: (x, y, z), где X - поперечная ось, Z - дальность вдоль оси радара,
-# Y - нормаль к плоскости XOZ. BP-карта строится в плоскости y=BP_PLANE_Y.
-# Геометрию лучше переносить из config_profile.json текущего эксперимента.
-BP_PLANE_Y = 0.0
-
-TX_POSITIONS = {
- 0: (-75.0 * 0.01, 0.0, 0.0),
- 1: ( 75.0 * 0.01, 0.0, 0.0),
-}
-
-RX_POSITIONS = {
- 0: ( 19.0 * 0.01, 0.0, 0.0),
- 1: ( 45.0 * 0.01, 0.0, 0.0),
- 2: (-45.0 * 0.01, 0.0, 0.0),
- 3: (-19.0 * 0.01, 0.0, 0.0),
-}
-
-
-@dataclass
-class MotionConfig:
- """
- Конфигурация speed-correction для interleaved Tx-by-frequency режима.
-
- Реальная схема измерения:
- Tx0(f0), Tx1(f0), Tx0(f1), Tx1(f1), ...
-
- Поэтому меж-Tx сдвиг A-сканов не используется. Коррекция движения делается
- только в частотной области до IFFT: для каждой частоты учитывается время,
- в которое эта частота была измерена данной Tx-группой.
-
- speed_m_s:
- Линейная скорость радара во время кадра.
-
- look_angle_deg:
- Угол между направлением движения и осью дальности Z.
-
- tx_sweep_time_s:
- Базовое время одной VNA-линейки частот. Для одной Tx-группы соседние
- частоты измеряются через N_tx таких временных шагов, поэтому effective
- frequency sweep этой Tx-группы равен N_tx * tx_sweep_time_s.
-
- motion_mode:
- 'int_minus' — полная intra-frequency correction к центру interleaved sweep;
- 'int_focus' — удалить аффинную по частоте часть phase(f), оставив
- focus-like остаток без чистого сдвига Z.
-
- pair_order_phys:
- Порядок физических Tx/Rx-пар в данных. Пары с одинаковым tx_phys имеют
- один interleave-slot и отличаются только Rx.
-
- direction_sign:
- Знак движения вдоль оси дальности. +1 означает, что при росте времени
- радар смещается в сторону увеличения Z.
- """
- speed_m_s: float = 0.0
- look_angle_deg: float = 0.0
- tx_sweep_time_s: float = 0.15
- motion_mode: str = 'int_minus'
- pair_order_phys: List[Tuple[int, int]] = field(default_factory=lambda: [
- (tx_phys, rx_phys)
- for tx_phys in sorted(OUTPUT_IDX)
- for rx_phys in sorted(INPUT_IDX)
- ])
- direction_sign: float = +1.0
-
-
-MOTION_CONFIG = MotionConfig(
- speed_m_s=SPEED_M_S,
- look_angle_deg=LOOK_ANGLE_DEG,
- tx_sweep_time_s=TX_SWEEP_TIME_S,
- motion_mode=MOTION_CORRECTION_MODE,
- pair_order_phys=[
- (tx_phys, rx_phys)
- for tx_phys in sorted(OUTPUT_IDX)
- for rx_phys in sorted(INPUT_IDX)
- ],
- direction_sign=+1.0,
-)
-
-# ══════════════════════════════════════════════════════
-# 0.3 НЕИЗМЕНЯЕМЫЕ ПАРАМЕТРЫ (ЛУЧШЕ НЕ ТРОГАТЬ)
-# ══════════════════════════════════════════════════════
-
-eps_r = 1.0
-v = 3e8 / np.sqrt(eps_r)
-
-
-def positions_to_xyz(position_dict):
- coords = []
- for idx in sorted(position_dict):
- pos = np.asarray(position_dict[idx], dtype=float)
- if pos.ndim == 0:
- pos = np.array([float(pos), 0.0, 0.0], dtype=float)
- elif pos.shape == (2,):
- pos = np.array([pos[0], pos[1], 0.0], dtype=float)
- elif pos.shape != (3,):
- raise ValueError(f'Позиция антенны {idx} должна быть x, (x,y) или (x,y,z), получено {pos}')
- coords.append(pos)
- return np.vstack(coords)
-
-
-tx_xyz = positions_to_xyz(TX_POSITIONS)
-rx_xyz = positions_to_xyz(RX_POSITIONS)
-x_tx, y_tx, z_tx = tx_xyz.T
-x_rx, y_rx, z_rx = rx_xyz.T
-
-# Сетка BP-карты.
-x_ant = np.concatenate([x_tx, x_rx])
-x_min, x_max = x_ant.min() - 2.0, x_ant.max() + 2.0
-z_min, z_max = 0.1, MAX_DEPTH
-NX_BP = 300
-NZ_BP = 300
-
-# Oversampling A-сканов: повышает плотность точек по t, но не физическое разрешение.
-BP_OVERSAMPLE = 8
-BP_WINDOW = True
-
-# Нормировка каналов Tx/Rx перед когерентным сложением.
-# Масштаб считается по |complex A-scan| в выбранном диапазоне глубин.
-PAIR_NORMALIZE = True
-PAIR_NORM_PERCENTILE = 50.0
-PAIR_NORM_DEPTH_MIN = MIN_DEPTH
-PAIR_NORM_DEPTH_MAX = MAX_DEPTH
-PAIR_NORM_EPS = 1e-15
-
-# Компенсация нормируется на точку под виртуальным центром пары на глубине COMP_REF_DEPTH.
-COMP_ANGLE_POWER = 0.0
-COMP_RANGE_WEIGHT_MAX = 5.0
-COMP_ANGLE_WEIGHT_MAX = 2.0
-COMP_WEIGHT_MAX = 8.0
-COMP_REF_DEPTH = 5.0
-BP_VALIDATE_COMPENSATION = False # clean mode: не считаем отдельную карту без compensation
-
-# Сглаживание только для удобства поиска/визуализации максимума.
-BP_SMOOTH_SIGMA = 1.5
-
-# Параметры поиска объектов на BP-карте.
-MAX_OBJECTS = 10
-BP_REGION_THRESH_FRAC = 0.75 # область объекта: связная область выше этой доли от локального пика
-BP_SUPPRESS_THRESH_FRAC = 0.2 # подавление: более широкая связная область вокруг найденного пика
-BP_SUPPRESS_USE_WINDOW = True # False: подавлять всю связанную область; True: ограничить окно вокруг пика
-BP_SUPPRESS_RX_CM = 80.0 # используется только если BP_SUPPRESS_USE_WINDOW=True
-BP_SUPPRESS_RZ_CM = 40.0 # используется только если BP_SUPPRESS_USE_WINDOW=True
-BP_MIN_REGION_AREA_CM2 = 10.0 # отсечение совсем мелких шумовых пятен
-
-# Компактный центроид вокруг локального максимума, устойчивее центроида всей вытянутой области.
-BP_CENTER_USE_COMPACT = True
-BP_CENTER_RX_CM = 60.0
-BP_CENTER_RZ_CM = 25.0
-BP_CENTER_THRESH_FRAC = 0.88
-BP_CENTER_WEIGHT_POWER = 2.0
-
-# Диагностика боковых лепестков coherent BP.
-BP_SIDELOBE_DETECT = True
-BP_SIDELOBE_RANGE_RMS_TOL_CM = 20.0 # RMS-разница бистатических глубин по всем парам
-BP_SIDELOBE_MIN_DX_CM = 35.0 # боковой лепесток должен быть заметно смещен по X
-BP_SIDELOBE_MAX_DZ_CM = 70.0 # но находиться примерно на той же глубине
-BP_SIDELOBE_MAX_REL_PEAK = 0.85 # кандидат должен быть слабее родительского максимума
-
-# Фазовая метрика внутри compact-области объекта.
-BP_PHASE_WEIGHT_POWER = 1.0
-
-# Локальная выраженность объекта над окружающим фоном.
-BP_LOCAL_BG_RX_CM = 120.0
-BP_LOCAL_BG_RZ_CM = 80.0
-BP_LOCAL_BG_PERCENTILE = 50.0
-BP_LOCAL_CONTRAST_EPS = 1e-12
-
-# Экспериментальный score для ранжирования найденных объектов.
-BP_SCORE_COMPUTE_INCOHERENT = True
-BP_SCORE_COH_PEAK_WEIGHT = 0.45
-BP_SCORE_COHERENCE_FACTOR_WEIGHT = 0.25
-BP_SCORE_PROMINENCE_WEIGHT = 0.20
-BP_SCORE_CONTRAST_WEIGHT = 0.10
-BP_SCORE_CONTRAST_CAP = 6.0
-BP_SCORE_CF_EPS = 1e-12
-
-
-def _build_phys_to_logical_maps(output_idx, input_idx):
- tx_map = {phys: k for k, phys in enumerate(sorted(output_idx))}
- rx_map = {phys: k for k, phys in enumerate(sorted(input_idx))}
- return tx_map, rx_map
-
-
-def _tx_event_order_from_pairs(pair_order_phys):
- tx_events = []
- for tx_phys, _ in pair_order_phys:
- if tx_phys not in tx_events:
- tx_events.append(tx_phys)
- return tx_events
-
-
-def compute_pair_timestamps(config: MotionConfig,
- output_idx=OUTPUT_IDX,
- input_idx=INPUT_IDX) -> Tuple[Dict[Tuple[int, int], Dict], List[Dict]]:
- """Возвращает interleave-slot для каждой логической Tx/Rx-пары."""
- tx_phys_to_log, rx_phys_to_log = _build_phys_to_logical_maps(output_idx, input_idx)
- expected_pairs = {(tx, rx) for tx in output_idx for rx in input_idx}
- observed_pairs = set(config.pair_order_phys)
- missing_pairs = expected_pairs - observed_pairs
- extra_pairs = observed_pairs - expected_pairs
- if missing_pairs:
- raise ValueError(f'pair_order_phys не содержит пары: {sorted(missing_pairs)}')
- if extra_pairs:
- raise ValueError(f'pair_order_phys содержит лишние пары: {sorted(extra_pairs)}')
- if len(config.pair_order_phys) != len(observed_pairs):
- raise ValueError('pair_order_phys содержит повторяющиеся пары')
-
- tx_event_order = _tx_event_order_from_pairs(config.pair_order_phys)
- if not tx_event_order:
- return {}, []
-
- n_tx_events = len(tx_event_order)
- pair_timestamps: Dict[Tuple[int, int], Dict] = {}
- rows: List[Dict] = []
-
- for order_idx, (tx_phys, rx_phys) in enumerate(config.pair_order_phys):
- if tx_phys not in tx_phys_to_log:
- raise ValueError(f'Tx {tx_phys} отсутствует в OUTPUT_IDX={output_idx}')
- if rx_phys not in rx_phys_to_log:
- raise ValueError(f'Rx {rx_phys} отсутствует в INPUT_IDX={input_idx}')
-
- event_idx = tx_event_order.index(tx_phys)
- row = {
- 'order_idx': order_idx,
- 'tx_event_idx': event_idx,
- 'tx_interleave_slot': event_idx,
- 'tx_interleave_slots': n_tx_events,
- 'tx_phys': tx_phys,
- 'rx_phys': rx_phys,
- 'i_tx': tx_phys_to_log[tx_phys],
- 'i_rx': rx_phys_to_log[rx_phys],
- 'base_sweep_time_s': config.tx_sweep_time_s,
- }
- rows.append(row)
- pair_timestamps[(row['i_tx'], row['i_rx'])] = row.copy()
-
- return pair_timestamps, rows
-
-
-def print_motion_summary(rows: List[Dict], config: MotionConfig):
- if config.motion_mode not in ('int_minus', 'int_focus'):
- raise ValueError("motion_mode must be 'int_minus' or 'int_focus'")
-
- n_slots = max((r['tx_interleave_slots'] for r in rows), default=len(OUTPUT_IDX))
- print(f'Motion correction: {config.motion_mode}; speed={config.speed_m_s:.3f} м/с, '
- f'angle={config.look_angle_deg:.2f}°, tx_sweep={config.tx_sweep_time_s*1e3:.2f} мс')
- print(f'Frequency timing: interleaved Tx-by-frequency; Tx slots={n_slots}; '
- f'effective Tx frequency span≈{n_slots*config.tx_sweep_time_s*1e3:.2f} мс')
- print(f" {'pair':<8} {'slot':>4} {'tx_phys':>7} {'rx_phys':>7}")
- for r in rows:
- pair = f"Tx{r['i_tx']}-Rx{r['i_rx']}"
- print(f" {pair:<8} {r['tx_interleave_slot']:>4d} {r['tx_phys']:>7d} {r['rx_phys']:>7d}")
-
-
-def compute_frequency_sample_times(freq_full: np.ndarray,
- f_start: float,
- f_stop: float,
- pair_info: Dict,
- sweep_time_s: float) -> Tuple[np.ndarray, np.ndarray, float]:
- """Возвращает индексы частот, абсолютное время каждой точки и центр Tx-sweep."""
- mask = (freq_full >= f_start) & (freq_full <= f_stop)
- idx = np.flatnonzero(mask)
- if idx.size == 0:
- raise ValueError('После частотной обрезки не осталось точек для phase correction')
-
- n_full = len(freq_full)
- if n_full < 2:
- return idx, np.zeros(idx.size, dtype=float), 0.0
-
- n_slots = int(pair_info.get('tx_interleave_slots', len(OUTPUT_IDX)))
- slot = int(pair_info.get('tx_interleave_slot', pair_info.get('tx_event_idx', 0)))
- dt_base = sweep_time_s / (n_full - 1)
-
- full_indices = np.arange(n_full, dtype=float)
- t_all = (n_slots * full_indices + slot) * dt_base
- t_abs = t_all[idx]
- t_center = 0.5 * (float(t_all[0]) + float(t_all[-1]))
- return idx, t_abs, t_center
-
-
-def apply_intra_sweep_phase_correction(s21: np.ndarray,
- freq_full: np.ndarray,
- pair_info: Dict,
- config: MotionConfig,
- wave_speed: float,
- f_start: float,
- f_stop: float):
- """Frequency-domain speed correction до IFFT для interleaved Tx-by-frequency."""
- if config.motion_mode not in ('int_minus', 'int_focus'):
- raise ValueError("motion_mode must be 'int_minus' or 'int_focus'")
-
- s21_corr = np.array(s21, dtype=np.complex128, copy=True)
- idx, t_abs, t_center_s = compute_frequency_sample_times(
- freq_full=freq_full,
- f_start=f_start,
- f_stop=f_stop,
- pair_info=pair_info,
- sweep_time_s=config.tx_sweep_time_s,
- )
-
- dt_intra = t_abs - t_center_s
- theta = np.radians(config.look_angle_deg)
- delta_range_intra = config.direction_sign * config.speed_m_s * np.cos(theta) * dt_intra
- delta_path_intra = 2.0 * delta_range_intra
- dtau_intra = delta_path_intra / wave_speed
-
- phi = 2.0 * np.pi * freq_full[idx] * dtau_intra
-
- # int_focus убирает постоянную + линейную по частоте часть phase(f).
- # Оставшийся нелинейный остаток улучшает фокус, почти не сдвигая Z.
- if config.motion_mode == 'int_focus' and np.any(dtau_intra != 0.0):
- f_rel = freq_full[idx].astype(float) - float(np.mean(freq_full[idx]))
- x_fit = np.column_stack([np.ones_like(f_rel), f_rel])
- beta_fit, *_ = np.linalg.lstsq(x_fit, phi, rcond=None)
- phi = phi - x_fit @ beta_fit
-
- if np.any(dtau_intra != 0.0):
- s21_corr[idx] *= np.exp(-1j * phi)
-
- meta = {
- 'enabled': bool(np.any(dtau_intra != 0.0)),
- 'motion_mode': config.motion_mode,
- 'freq_idx': idx,
- 't_abs_s': t_abs,
- 'dt_intra_s': dt_intra,
- 't_center_s': t_center_s,
- 'intra_dtau_s': dtau_intra,
- 'total_dtau_s': dtau_intra,
- 'delta_range_m': delta_range_intra,
- 'delta_path_m': delta_path_intra,
- 'phi_rad': phi,
- }
- return s21_corr, meta
-
-
-pair_timestamps, pair_timing_rows = compute_pair_timestamps(MOTION_CONFIG)
-print_motion_summary(pair_timing_rows, MOTION_CONFIG)
-
-# ══════════════════════════════════════════════════════
-# 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
-
- 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)
-
- return s21_data, freq_data
-
-
-def compute_background(bg_path, input_idx, output_idx):
- bg_path = Path(bg_path)
- snapshots = [s for s in sorted(bg_path.glob('*/')) if s.is_dir()]
-
- if len(snapshots) == 0:
- print('Фоновые снимки не найдены, BG_SUBTRACT отключен.')
- return None
-
- print(f'Вычисление фона по {len(snapshots)} снимкам...', end=' ', flush=True)
- 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=np.complex128)
- 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)}')
- return bg
-
-
-s21_data, freq_data = load_mimo_data(DATA_PATH, INPUT_IDX, OUTPUT_IDX)
-N_tx = len(OUTPUT_IDX)
-N_rx = len(INPUT_IDX)
-N_pairs = len(s21_data)
-
-assert len(x_tx) == N_tx, f'x_tx должен содержать {N_tx} элементов'
-assert len(x_rx) == N_rx, f'x_rx должен содержать {N_rx} элементов'
-assert N_pairs > 0, 'Не найдено ни одной Tx/Rx-пары. Проверьте DATA_PATH.'
-
-print('Геометрия антенн, [м]:')
-for local_i, phys_i in enumerate(sorted(TX_POSITIONS)):
- print(f' Tx{local_i} / o{phys_i}: x={x_tx[local_i]:+.3f}, y={y_tx[local_i]:+.3f}, z={z_tx[local_i]:+.3f}')
-for local_j, phys_j in enumerate(sorted(RX_POSITIONS)):
- print(f' Rx{local_j} / i{phys_j}: x={x_rx[local_j]:+.3f}, y={y_rx[local_j]:+.3f}, z={z_rx[local_j]:+.3f}')
-
-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]
-freq_mask = (freqs >= F_START) & (freqs <= F_STOP)
-freqs_bp = freqs[freq_mask]
-
-if len(freqs_bp) < 2:
- raise ValueError('В выбранном частотном диапазоне меньше двух точек.')
-
-f_min = float(freqs_bp[0])
-f_max = float(freqs_bp[-1])
-BW = f_max - f_min
-df_values = np.diff(freqs_bp)
-df_median = float(np.median(df_values))
-df_min = float(np.min(df_values))
-df_max = float(np.max(df_values))
-df_rel_spread = (df_max - df_min) / (df_median + 1e-30)
-range_resolution = v / (2 * BW)
-unambiguous_depth = v / (2 * df_median)
-unambiguous_total_path = v / df_median
-
-print(f'Загружено пар Tx/Rx: {N_pairs}')
-print(f'Частотный диапазон BP: {f_min/1e9:.3f} - {f_max/1e9:.3f} ГГц')
-print(f'Точек частоты в BP-диапазоне: {len(freqs_bp)}')
-print(f'Шаг частоты df: median={df_median/1e6:.3f} МГц, '
- f'min={df_min/1e6:.3f} МГц, max={df_max/1e6:.3f} МГц')
-print(f'Неравномерность df: {(df_rel_spread*100):.3f}% от median')
-print(f'Полоса B: {BW/1e9:.3f} ГГц')
-print(f'Теоретический предел разрешения по глубине deltaZ = {range_resolution*100:.2f} см')
-print(f'Unambiguous range по глубине = {unambiguous_depth:.2f} м '
- f'(max total path = {unambiguous_total_path:.2f} м)')
-print(f'BP_OVERSAMPLE = {BP_OVERSAMPLE}')
-print(f'MOTION_CORRECTION_MODE = {MOTION_CORRECTION_MODE}')
-print('FREQUENCY_TIMING = interleaved_tx_by_frequency')
-
-
-# ══════════════════════════════════════════════════════
-# 2. OVERSAMPLED A-СКАНЫ
-# ══════════════════════════════════════════════════════
-
-def compute_ascan_bp(s21, freq, f_start, f_stop, window=True, oversample=8):
- """
- S21(f) -> A-скан с правильным частотным сдвигом и oversampling.
-
- Частотный шаг df остается тем же, а n_fft увеличивается в oversample раз.
- Поэтому временная сетка становится плотнее: dt = 1 / (n_fft * df).
- """
- mask = (freq >= f_start) & (freq <= f_stop)
- freq_cut = freq[mask]
- s21_cut = s21[mask]
-
- if len(freq_cut) < 2:
- raise ValueError('После обрезки по частоте осталось меньше двух точек.')
-
- df = float(np.median(np.diff(freq_cut)))
- n = len(freq_cut)
- k0 = int(round(freq_cut[0] / df))
-
- min_len = 2 * (k0 + n - 1)
- n_fft_base = 1 << int(np.ceil(np.log2(min_len)))
- n_fft = int(n_fft_base * oversample)
-
- dt = 1.0 / (n_fft * df)
- t_sec = np.arange(n_fft, dtype=float) * dt
-
- s = s21_cut * np.hanning(n) if window else s21_cut.copy()
-
- H = np.zeros(n_fft, dtype=np.complex128)
- H[k0:k0 + n] = s
-
- h_complex = np.fft.ifft(H)
- a_abs = np.abs(h_complex)
-
- return t_sec, a_abs, h_complex, n_fft_base, n_fft
-
-
-
-print('Вычисление oversampled A-сканов...', end=' ', flush=True)
-A_bp = {}
-H_bp = {}
-T_bp = {}
-Z_bp = {}
-n_fft_info = {}
-pair_norm_info = {}
-phase_meta = {}
-
-for (i, j), s21 in s21_data.items():
- s21_proc = np.array(s21, dtype=np.complex128, copy=True)
- if BG_SUBTRACT and background is not None and (i, j) in background:
- s21_proc = s21_proc - background[(i, j)]
-
- if (i, j) not in pair_timestamps:
- raise KeyError(f'Нет временной информации для пары {(i, j)}')
-
- s21_corr, meta = apply_intra_sweep_phase_correction(
- s21=s21_proc,
- freq_full=freq_data[(i, j)],
- pair_info=pair_timestamps[(i, j)],
- config=MOTION_CONFIG,
- wave_speed=v,
- f_start=F_START,
- f_stop=F_STOP,
- )
- phase_meta[(i, j)] = meta
-
- t_pair, a_pair, h_pair, n_fft_base, n_fft = compute_ascan_bp(
- s21_corr,
- freq_data[(i, j)],
- f_start=F_START,
- f_stop=F_STOP,
- window=BP_WINDOW,
- oversample=BP_OVERSAMPLE,
- )
- T_bp[(i, j)] = t_pair
- Z_bp[(i, j)] = t_pair * v / 2
- A_bp[(i, j)] = a_pair
- H_bp[(i, j)] = h_pair
- n_fft_info[(i, j)] = (n_fft_base, n_fft)
-
-# Robust per-pair amplitude normalization. It equalizes channel scale, not phase.
-for key in sorted(H_bp.keys()):
- z_axis = Z_bp[key]
- h_abs = np.abs(H_bp[key])
- norm_mask = (z_axis >= PAIR_NORM_DEPTH_MIN) & (z_axis <= PAIR_NORM_DEPTH_MAX)
- if not np.any(norm_mask):
- norm_mask = np.ones_like(z_axis, dtype=bool)
-
- median_val = float(np.median(h_abs[norm_mask]))
- p75_val = float(np.percentile(h_abs[norm_mask], 75))
- p95_val = float(np.percentile(h_abs[norm_mask], 95))
- scale = float(np.percentile(h_abs[norm_mask], PAIR_NORM_PERCENTILE))
- if not np.isfinite(scale) or scale <= PAIR_NORM_EPS:
- scale = 1.0
-
- pair_norm_info[key] = {
- 'median': median_val,
- 'p75': p75_val,
- 'p95': p95_val,
- 'scale': scale,
- }
-
- if PAIR_NORMALIZE:
- H_bp[key] = H_bp[key] / (scale + PAIR_NORM_EPS)
- A_bp[key] = np.abs(H_bp[key])
-
-
-z_h_bp = Z_bp[first_key]
-t_h_bp = T_bp[first_key]
-print('готово.')
-print(f'dt = {(t_h_bp[1] - t_h_bp[0])*1e12:.2f} пс')
-print(f'dz_sample = {(z_h_bp[1] - z_h_bp[0])*100:.3f} см')
-if phase_meta:
- delta_ranges = np.concatenate([m['delta_range_m'] for m in phase_meta.values()])
- total_dtau_vals = np.concatenate([m['total_dtau_s'] for m in phase_meta.values()])
- phis = np.concatenate([m['phi_rad'] for m in phase_meta.values()])
- print(f'Motion mode до IFFT: {MOTION_CONFIG.motion_mode}')
- print('Frequency timing: interleaved_tx_by_frequency')
- print(f'Total dtau(f) range = {total_dtau_vals.min()*1e9:+.3f}..{total_dtau_vals.max()*1e9:+.3f} нс')
- print(f'Intra-sweep dz range = {delta_ranges.min()*100:+.3f}..{delta_ranges.max()*100:+.3f} см')
- print(f'Motion phi range = {phis.min():+.3e}..{phis.max():+.3e} рад')
-
-print('\n' + '=' * 86)
-print(f' НОРМИРОВКА КАНАЛОВ Tx/Rx: enabled={PAIR_NORMALIZE}, '
- f'percentile={PAIR_NORM_PERCENTILE:.1f}, '
- f'z=[{PAIR_NORM_DEPTH_MIN:.2f}, {PAIR_NORM_DEPTH_MAX:.2f}] м')
-print('=' * 86)
-print(f" {'Pair':<8} {'median':>12} {'p75':>12} {'p95':>12} {'scale':>12} {'rel_scale':>12}")
-print('-' * 86)
-scales = np.array([v['scale'] for v in pair_norm_info.values()], dtype=float)
-scale_ref = float(np.median(scales)) if len(scales) else 1.0
-for key in sorted(pair_norm_info.keys()):
- info = pair_norm_info[key]
- rel_scale = info['scale'] / (scale_ref + PAIR_NORM_EPS)
- print(f" Tx{key[0]}-Rx{key[1]:<3} {info['median']:>12.4e} {info['p75']:>12.4e} "
- f"{info['p95']:>12.4e} {info['scale']:>12.4e} {rel_scale:>12.3f}")
-print('=' * 86)
-
-
-# ══════════════════════════════════════════════════════
-# 3. TIME-DOMAIN COHERENT BACKPROJECTION
-# ══════════════════════════════════════════════════════
-
-x_grid_bp = np.linspace(x_min, x_max, NX_BP)
-z_grid_bp = np.linspace(z_min, z_max, NZ_BP)
-XX_bp, ZZ_bp = np.meshgrid(x_grid_bp, z_grid_bp)
-depth_gate = (ZZ_bp >= MIN_DEPTH) & (ZZ_bp <= MAX_DEPTH)
-
-
-def bistatic_ranges(i_tx, i_rx, XX, ZZ, yy=BP_PLANE_Y):
- Rtx = np.sqrt((XX - x_tx[i_tx])**2 + (yy - y_tx[i_tx])**2 + (ZZ - z_tx[i_tx])**2)
- Rrx = np.sqrt((XX - x_rx[i_rx])**2 + (yy - y_rx[i_rx])**2 + (ZZ - z_rx[i_rx])**2)
- return Rtx, Rrx
-
-
-def antenna_boresight_cos_z(R, z_ant, ZZ):
- # В локальных координатах радара все антенны смотрят вдоль +Z.
- return (ZZ - z_ant) / (R + 1e-12)
-
-
-def attenuation_components_map(i_tx, i_rx, XX, ZZ):
- Rtx, Rrx = bistatic_ranges(i_tx, i_rx, XX, ZZ)
- geo = 1.0 / (Rtx * Rrx + 1e-12)
- cos_tx = antenna_boresight_cos_z(Rtx, z_tx[i_tx], ZZ)
- cos_rx = antenna_boresight_cos_z(Rrx, z_rx[i_rx], ZZ)
- angle = cos_tx**2 * cos_rx**2
- return geo + 1e-30, angle + 1e-30
-
-
-def attenuation_components_at_ref_depth(i_tx, i_rx, z_ref):
- xc = (x_tx[i_tx] + x_rx[i_rx]) / 2.0
- Rtx, Rrx = bistatic_ranges(i_tx, i_rx, xc, z_ref)
- geo = 1.0 / (Rtx * Rrx + 1e-12)
- cos_tx = antenna_boresight_cos_z(Rtx, z_tx[i_tx], z_ref)
- cos_rx = antenna_boresight_cos_z(Rrx, z_rx[i_rx], z_ref)
- angle = cos_tx**2 * cos_rx**2
- return geo + 1e-30, angle + 1e-30
-
-
-def bp_compensation_weight(i_tx, i_rx, XX, ZZ):
- geo, angle = attenuation_components_map(i_tx, i_rx, XX, ZZ)
- geo_ref, angle_ref = attenuation_components_at_ref_depth(i_tx, i_rx, COMP_REF_DEPTH)
-
- geo_norm = geo / geo_ref
- angle_norm = angle / angle_ref
-
- range_weight = 1.0 / (geo_norm ** COMP_RANGE_POWER + 1e-12)
- angle_weight = 1.0 / (angle_norm ** COMP_ANGLE_POWER + 1e-12)
-
- range_weight = np.clip(range_weight, 0.0, COMP_RANGE_WEIGHT_MAX)
- angle_weight = np.clip(angle_weight, 0.0, COMP_ANGLE_WEIGHT_MAX)
-
- weight = range_weight * angle_weight
- return np.clip(weight, 0.0, COMP_WEIGHT_MAX)
-
-
-def interpolate_ascan_amplitude(tau, t_axis, a_axis):
- return np.interp(tau.ravel(), t_axis, a_axis, left=0.0, right=0.0).reshape(tau.shape)
-
-
-def interpolate_ascan_complex(tau, t_axis, h_axis):
- h_real = np.interp(tau.ravel(), t_axis, h_axis.real, left=0.0, right=0.0)
- h_imag = np.interp(tau.ravel(), t_axis, h_axis.imag, left=0.0, right=0.0)
- return (h_real + 1j * h_imag).reshape(tau.shape)
-
-
-def backproject_coherent(H, T, compensate=True):
- bp_complex = np.zeros_like(XX_bp, dtype=np.complex128)
- contribution_count = np.zeros_like(XX_bp, dtype=float)
-
- for i in range(N_tx):
- for j in range(N_rx):
- key = (i, j)
- if key not in H:
- continue
-
- Rtx, Rrx = bistatic_ranges(i, j, XX_bp, ZZ_bp)
- tau_ref = (Rtx + Rrx) / v
- tau = tau_ref
- valid = depth_gate & (tau >= T[key][0]) & (tau <= T[key][-1])
-
- h_tau = interpolate_ascan_complex(tau, T[key], H[key])
- h_tau = np.where(valid, h_tau, 0.0 + 0.0j)
-
- if compensate:
- w = bp_compensation_weight(i, j, XX_bp, ZZ_bp)
- w = np.where(valid, w, 0.0)
- else:
- w = np.where(valid, 1.0, 0.0)
-
- bp_complex += h_tau * w
- contribution_count += valid.astype(float)
-
- bp_complex = bp_complex / (contribution_count + 1e-12)
- bp_complex = np.where(depth_gate, bp_complex, 0.0 + 0.0j)
- bp_abs = np.abs(bp_complex)
- return bp_abs, bp_complex
-
-
-def backproject_coherent_and_incoherent(H, T, compensate=True):
- """Одним проходом строит coherent |sum h| и incoherent sum |h| BP-карты."""
- bp_complex = np.zeros_like(XX_bp, dtype=np.complex128)
- bp_incoherent = np.zeros_like(XX_bp, dtype=float)
- contribution_count = np.zeros_like(XX_bp, dtype=float)
-
- for i in range(N_tx):
- for j in range(N_rx):
- key = (i, j)
- if key not in H:
- continue
-
- Rtx, Rrx = bistatic_ranges(i, j, XX_bp, ZZ_bp)
- tau_ref = (Rtx + Rrx) / v
- tau = tau_ref
- valid = depth_gate & (tau >= T[key][0]) & (tau <= T[key][-1])
-
- h_tau = interpolate_ascan_complex(tau, T[key], H[key])
- h_tau = np.where(valid, h_tau, 0.0 + 0.0j)
-
- if compensate:
- w = bp_compensation_weight(i, j, XX_bp, ZZ_bp)
- w = np.where(valid, w, 0.0)
- else:
- w = np.where(valid, 1.0, 0.0)
-
- bp_complex += h_tau * w
- bp_incoherent += np.abs(h_tau) * w
- contribution_count += valid.astype(float)
-
- bp_complex = bp_complex / (contribution_count + 1e-12)
- bp_complex = np.where(depth_gate, bp_complex, 0.0 + 0.0j)
- bp_abs = np.abs(bp_complex)
-
- bp_incoherent = bp_incoherent / (contribution_count + 1e-12)
- bp_incoherent = np.where(depth_gate, bp_incoherent, 0.0)
-
- bp_cf = bp_abs / (bp_incoherent + BP_SCORE_CF_EPS)
- bp_cf = np.where(depth_gate, bp_cf, 0.0)
- bp_cf = np.clip(bp_cf, 0.0, 1.0)
- return bp_abs, bp_complex, bp_incoherent, bp_cf
-
-
-def normalize_bp_map(bp_raw, smooth_sigma=BP_SMOOTH_SIGMA):
- bp_norm = bp_raw / (bp_raw.max() + 1e-12)
- bp_norm = gaussian_filter(bp_norm, sigma=smooth_sigma)
- bp_norm = np.where(depth_gate, bp_norm, 0.0)
- return bp_norm / (bp_norm.max() + 1e-12)
-
-def component_containing_peak(image, iz, ix, threshold, window_mask=None):
- mask = image >= threshold
- if window_mask is not None:
- mask &= window_mask
-
- labels, n_labels = label(mask, structure=np.ones((3, 3), dtype=int))
- if n_labels == 0 or labels[iz, ix] == 0:
- fallback = np.zeros_like(image, dtype=bool)
- fallback[iz, ix] = True
- return fallback
-
- return labels == labels[iz, ix]
-
-
-def weighted_centroid(image, region_mask, threshold=0.0):
- values = image[region_mask]
- weights = np.clip(values - threshold, 0.0, None)
- if weights.sum() <= 1e-15:
- weights = values.copy()
- if weights.sum() <= 1e-15:
- iz, ix = np.argwhere(region_mask)[0]
- return x_grid_bp[ix], z_grid_bp[iz]
-
- x_vals = XX_bp[region_mask]
- z_vals = ZZ_bp[region_mask]
- return (x_vals * weights).sum() / weights.sum(), (z_vals * weights).sum() / weights.sum()
-
-
-
-def compact_peak_centroid(image, iz, ix, peak):
- x0 = x_grid_bp[ix]
- z0 = z_grid_bp[iz]
- window_mask = (
- (np.abs(XX_bp - x0) <= BP_CENTER_RX_CM / 100.0) &
- (np.abs(ZZ_bp - z0) <= BP_CENTER_RZ_CM / 100.0)
- )
- threshold = BP_CENTER_THRESH_FRAC * peak
- center_mask = window_mask & (image >= threshold)
-
- if center_mask.sum() == 0:
- center_mask = window_mask.copy()
- center_mask[iz, ix] = True
-
- values = image[center_mask]
- weights = np.clip(values - threshold, 0.0, None) ** BP_CENTER_WEIGHT_POWER
- if weights.sum() <= 1e-15:
- weights = values.copy()
- if weights.sum() <= 1e-15:
- return x0, z0, center_mask
-
- x_vals = XX_bp[center_mask]
- z_vals = ZZ_bp[center_mask]
- x_c = (x_vals * weights).sum() / weights.sum()
- z_c = (z_vals * weights).sum() / weights.sum()
- return x_c, z_c, center_mask
-
-def find_bp_objects(bp_image):
- """
- CLEAN-подобный поиск объектов на BP-карте.
-
- Для каждого шага берется максимум рабочей карты, вокруг него выделяется
- связная область выше BP_REGION_THRESH_FRAC от локального пика, затем
- считается взвешенный центроид этой области. После этого более широкая
- область вокруг той же цели подавляется на рабочей карте.
- """
- work = bp_image.copy()
- objects = []
- global_peak = float(work.max())
- stop_level = BP_OBJECT_MIN_FRAC * global_peak
- dx_cm = abs(x_grid_bp[1] - x_grid_bp[0]) * 100
- dz_cm = abs(z_grid_bp[1] - z_grid_bp[0]) * 100
- pixel_area_cm2 = dx_cm * dz_cm
-
- for step in range(MAX_OBJECTS):
- peak = float(work.max())
- if peak <= stop_level or peak <= 0:
- break
-
- iz, ix = np.unravel_index(np.argmax(work), work.shape)
- x_peak_local = x_grid_bp[ix]
- z_peak_local = z_grid_bp[iz]
-
- region_threshold = BP_REGION_THRESH_FRAC * peak
- region_mask = component_containing_peak(work, iz, ix, region_threshold)
- region_area_cm2 = float(region_mask.sum() * pixel_area_cm2)
-
- if region_area_cm2 < BP_MIN_REGION_AREA_CM2:
- work[iz, ix] = 0.0
- continue
-
- x_region_centroid, z_region_centroid = weighted_centroid(work, region_mask, threshold=region_threshold)
- if BP_CENTER_USE_COMPACT:
- x_centroid, z_centroid, center_mask = compact_peak_centroid(work, iz, ix, peak)
- else:
- x_centroid, z_centroid = x_region_centroid, z_region_centroid
- center_mask = region_mask.copy()
-
- center_area_cm2 = float(center_mask.sum() * pixel_area_cm2)
- region_values = work[region_mask]
- objects.append({
- 'index': len(objects) + 1,
- 'ix_peak': int(ix),
- 'iz_peak': int(iz),
- 'x_peak': float(x_peak_local),
- 'z_peak': float(z_peak_local),
- 'x': float(x_centroid),
- 'z': float(z_centroid),
- 'x_region': float(x_region_centroid),
- 'z_region': float(z_region_centroid),
- 'peak': peak,
- 'area_cm2': region_area_cm2,
- 'center_area_cm2': center_area_cm2,
- 'region_mask': region_mask.copy(),
- 'center_mask': center_mask.copy(),
- 'mean_value': float(region_values.mean()),
- 'sum_value': float(region_values.sum()),
- })
-
- if BP_SUPPRESS_USE_WINDOW:
- suppress_window = (
- (np.abs(XX_bp - x_peak_local) <= BP_SUPPRESS_RX_CM / 100.0) &
- (np.abs(ZZ_bp - z_peak_local) <= BP_SUPPRESS_RZ_CM / 100.0)
- )
- else:
- suppress_window = None
-
- suppress_threshold = BP_SUPPRESS_THRESH_FRAC * peak
- suppress_mask = component_containing_peak(
- work, iz, ix, suppress_threshold, window_mask=suppress_window
- )
-
- # Если широкий порог дал слишком маленькую область, подавляем хотя бы область детекции.
- if suppress_mask.sum() < region_mask.sum():
- suppress_mask = region_mask
-
- work[suppress_mask] = 0.0
-
- return objects, work
-
-
-def circular_phase_stats(bp_complex_map, mask):
- if mask.sum() == 0:
- return np.nan, np.nan, np.nan
-
- h = bp_complex_map[mask]
- amp = np.abs(h)
- valid = amp > 0
- if not np.any(valid):
- return np.nan, np.nan, np.nan
-
- phase = np.angle(h[valid])
- weights = amp[valid] ** BP_PHASE_WEIGHT_POWER
- if weights.sum() <= 1e-15:
- weights = np.ones_like(phase)
-
- vec = np.sum(weights * np.exp(1j * phase)) / (np.sum(weights) + 1e-15)
- phase_mean = float(np.angle(vec))
- phase_coherence = float(np.abs(vec))
- phase_circular_variance = float(1.0 - phase_coherence)
- return phase_mean, phase_coherence, phase_circular_variance
-
-
-def add_phase_metrics(objects, bp_complex_map):
- for obj in objects:
- mask = obj.get('center_mask', obj['region_mask'])
- phase_mean, phase_coh, phase_var = circular_phase_stats(bp_complex_map, mask)
- obj['phase_mean_rad'] = phase_mean
- obj['phase_coherence'] = phase_coh
- obj['phase_circular_variance'] = phase_var
- return objects
-
-
-def add_local_prominence_metrics(objects, bp_image):
- for obj in objects:
- x0 = obj['x_peak']
- z0 = obj['z_peak']
- outer_mask = (
- (np.abs(XX_bp - x0) <= BP_LOCAL_BG_RX_CM / 100.0) &
- (np.abs(ZZ_bp - z0) <= BP_LOCAL_BG_RZ_CM / 100.0) &
- depth_gate
- )
- bg_mask = outer_mask & (~obj['region_mask'])
- if bg_mask.sum() < 10:
- bg_mask = depth_gate & (~obj['region_mask'])
-
- if bg_mask.sum() == 0:
- bg_level = 0.0
- bg_p75 = 0.0
- else:
- bg_values = bp_image[bg_mask]
- bg_level = float(np.percentile(bg_values, BP_LOCAL_BG_PERCENTILE))
- bg_p75 = float(np.percentile(bg_values, 75))
-
- peak = float(obj['peak'])
- obj['local_bg'] = bg_level
- obj['local_bg_p75'] = bg_p75
- obj['prominence'] = peak - bg_level
- obj['contrast'] = peak / (bg_level + BP_LOCAL_CONTRAST_EPS)
- return objects
-
-
-def add_incoherent_support_metrics(objects, bp_incoherent_image, bp_cf_image=None):
- for obj in objects:
- if bp_incoherent_image is None:
- obj['incoh_peak'] = 0.0
- obj['incoh_mean'] = 0.0
- obj['incoh_center_mean'] = 0.0
- obj['coherence_factor_peak_raw'] = 0.0
- obj['coherence_factor_center_raw'] = 0.0
- obj['coherence_factor_peak'] = 0.0
- obj['coherence_factor_center'] = 0.0
- continue
-
- region_mask = obj['region_mask']
- center_mask = obj.get('center_mask', region_mask)
- if region_mask.sum() == 0:
- obj['incoh_peak'] = 0.0
- obj['incoh_mean'] = 0.0
- else:
- region_values = bp_incoherent_image[region_mask]
- obj['incoh_peak'] = float(region_values.max())
- obj['incoh_mean'] = float(region_values.mean())
-
- if center_mask.sum() == 0:
- obj['incoh_center_mean'] = obj['incoh_mean']
- else:
- obj['incoh_center_mean'] = float(bp_incoherent_image[center_mask].mean())
-
- if bp_cf_image is None:
- obj['coherence_factor_peak_raw'] = float(
- obj.get('peak', 0.0) / (obj['incoh_peak'] + BP_SCORE_CF_EPS)
- )
- obj['coherence_factor_center_raw'] = float(
- obj.get('mean_value', 0.0) / (obj['incoh_center_mean'] + BP_SCORE_CF_EPS)
- )
- else:
- iz_peak = int(obj.get('iz_peak', 0))
- ix_peak = int(obj.get('ix_peak', 0))
- obj['coherence_factor_peak_raw'] = float(bp_cf_image[iz_peak, ix_peak])
- if center_mask.sum() == 0:
- obj['coherence_factor_center_raw'] = obj['coherence_factor_peak_raw']
- else:
- obj['coherence_factor_center_raw'] = float(bp_cf_image[center_mask].mean())
-
- obj['coherence_factor_peak'] = float(np.clip(obj['coherence_factor_peak_raw'], 0.0, 1.0))
- obj['coherence_factor_center'] = float(np.clip(obj['coherence_factor_center_raw'], 0.0, 1.0))
- return objects
-
-
-def _contrast_score_unit(contrast):
- if not np.isfinite(contrast):
- return 0.0
- if BP_SCORE_CONTRAST_CAP <= 1.0:
- return 0.0
- return float(np.clip((contrast - 1.0) / (BP_SCORE_CONTRAST_CAP - 1.0), 0.0, 1.0))
-
-
-def add_bp_score_metrics(objects):
- total_weight = (
- BP_SCORE_COH_PEAK_WEIGHT +
- BP_SCORE_COHERENCE_FACTOR_WEIGHT +
- BP_SCORE_PROMINENCE_WEIGHT +
- BP_SCORE_CONTRAST_WEIGHT
- )
- if total_weight <= 0:
- total_weight = 1.0
-
- for obj in objects:
- coh_peak_score = float(np.clip(obj.get('peak', 0.0), 0.0, 1.0))
- coherence_factor_score = float(np.clip(obj.get('coherence_factor_peak', 0.0), 0.0, 1.0))
- prominence_score = float(np.clip(obj.get('prominence', 0.0), 0.0, 1.0))
- contrast_score = _contrast_score_unit(obj.get('contrast', np.nan))
-
- score_combined = (
- BP_SCORE_COH_PEAK_WEIGHT * coh_peak_score +
- BP_SCORE_COHERENCE_FACTOR_WEIGHT * coherence_factor_score +
- BP_SCORE_PROMINENCE_WEIGHT * prominence_score +
- BP_SCORE_CONTRAST_WEIGHT * contrast_score
- ) / total_weight
-
- obj['score_old'] = coh_peak_score
- obj['score_new'] = float(score_combined)
- obj['score_coh_peak_part'] = coh_peak_score
- obj['score_cf_part'] = coherence_factor_score
- obj['score_prominence_part'] = prominence_score
- obj['score_contrast_part'] = contrast_score
- obj['score_selected'] = obj['score_new'] if BP_SCORE_MODE == 'combined' else obj['score_old']
- return objects
-
-
-def prepare_bp_objects_for_display(objects, remove_sidelobes=True):
- if remove_sidelobes:
- visible = [obj for obj in objects if not obj.get('sidelobe_candidate', False)]
- else:
- visible = list(objects)
-
- if BP_SCORE_MODE == 'combined':
- visible = sorted(visible, key=lambda obj: obj.get('score_new', 0.0), reverse=True)
- else:
- visible = sorted(visible, key=lambda obj: obj.get('score_old', obj.get('peak', 0.0)), reverse=True)
-
- for rank, obj in enumerate(visible, start=1):
- obj['display_index'] = rank
- return visible
-
-
-def bistatic_depth_signature(x_obj, z_obj):
- signature = []
- for i in range(N_tx):
- for j in range(N_rx):
- Rtx, Rrx = bistatic_ranges(i, j, x_obj, z_obj)
- signature.append(0.5 * (Rtx + Rrx))
- return np.array(signature, dtype=float)
-
-
-def mark_sidelobe_candidates(objects):
- for obj in objects:
- obj['sidelobe_candidate'] = False
- obj['sidelobe_parent'] = None
- obj['sidelobe_range_rms_cm'] = np.nan
- obj['sidelobe_dx_cm'] = np.nan
- obj['sidelobe_dz_cm'] = np.nan
-
- if not BP_SIDELOBE_DETECT:
- return objects
-
- signatures = [bistatic_depth_signature(obj['x'], obj['z']) for obj in objects]
-
- for k, obj in enumerate(objects):
- best_parent = None
- best_rms_cm = np.inf
- best_dx_cm = np.nan
- best_dz_cm = np.nan
-
- for p in range(k):
- parent = objects[p]
- rel_peak = obj['peak'] / (parent['peak'] + 1e-12)
- dx_cm = abs(obj['x'] - parent['x']) * 100.0
- dz_cm = abs(obj['z'] - parent['z']) * 100.0
- rms_cm = float(np.sqrt(np.mean((signatures[k] - signatures[p])**2)) * 100.0)
-
- is_candidate = (
- rel_peak <= BP_SIDELOBE_MAX_REL_PEAK and
- dx_cm >= BP_SIDELOBE_MIN_DX_CM and
- dz_cm <= BP_SIDELOBE_MAX_DZ_CM and
- rms_cm <= BP_SIDELOBE_RANGE_RMS_TOL_CM
- )
-
- if is_candidate and rms_cm < best_rms_cm:
- best_parent = parent
- best_rms_cm = rms_cm
- best_dx_cm = dx_cm
- best_dz_cm = dz_cm
-
- if best_parent is not None:
- obj['sidelobe_candidate'] = True
- obj['sidelobe_parent'] = best_parent['index']
- obj['sidelobe_range_rms_cm'] = best_rms_cm
- obj['sidelobe_dx_cm'] = best_dx_cm
- obj['sidelobe_dz_cm'] = best_dz_cm
-
- return objects
-
-
-if BP_SCORE_COMPUTE_INCOHERENT:
- print('Расчет coherent + incoherent time-domain BP...', end=' ', flush=True)
- bp_raw, bp_complex, bp_incoh_raw, bp_cf_map = backproject_coherent_and_incoherent(H_bp, T_bp, compensate=True)
- bp_incoh_map_s = normalize_bp_map(bp_incoh_raw)
-else:
- print('Расчет coherent time-domain BP...', end=' ', flush=True)
- bp_raw, bp_complex = backproject_coherent(H_bp, T_bp, compensate=True)
- bp_incoh_raw = None
- bp_incoh_map_s = None
- bp_cf_map = None
-bp_map_s = normalize_bp_map(bp_raw)
-print('готово.')
-print(f'BP score mode: {BP_SCORE_MODE} (peak=старый, combined=экспериментальный)')
-
-if BP_VALIDATE_COMPENSATION:
- print('Расчет coherent BP без compensation для валидации...', end=' ', flush=True)
- bp_raw_nocomp, bp_complex_nocomp = backproject_coherent(H_bp, T_bp, compensate=False)
- bp_map_nocomp_s = normalize_bp_map(bp_raw_nocomp)
- print('готово.')
-else:
- bp_raw_nocomp = None
- bp_complex_nocomp = None
- bp_map_nocomp_s = None
-
-bp_objects_all, bp_residual = find_bp_objects(bp_map_s)
-bp_objects_all = add_phase_metrics(bp_objects_all, bp_complex)
-bp_objects_all = add_local_prominence_metrics(bp_objects_all, bp_map_s)
-bp_objects_all = add_incoherent_support_metrics(bp_objects_all, bp_incoh_map_s, bp_cf_map)
-bp_objects_all = mark_sidelobe_candidates(bp_objects_all)
-bp_objects_all = add_bp_score_metrics(bp_objects_all)
-
-bp_map_display = bp_map_s.copy()
-if BP_REMOVE_SIDELOBE_OBJECTS:
- for obj in bp_objects_all:
- if obj['sidelobe_candidate']:
- bp_map_display[obj['region_mask']] = 0.0
-bp_objects = prepare_bp_objects_for_display(bp_objects_all, remove_sidelobes=BP_REMOVE_SIDELOBE_OBJECTS)
-bp_detected_object_count = len(bp_objects)
-if bp_detected_object_count > BP_MAX_DETECTED_OBJECTS_TO_DRAW:
- bp_objects_to_plot = []
-else:
- bp_objects_to_plot = bp_objects[:BP_DRAW_TOP_M_OBJECTS]
-
-iz_max, ix_max = np.unravel_index(np.argmax(bp_map_s), bp_map_s.shape)
-x_peak = x_grid_bp[ix_max]
-z_peak = z_grid_bp[iz_max]
-peak_value = bp_map_s[iz_max, ix_max]
-main_obj = bp_objects[0] if bp_objects else None
-
-target_results = [
- {
- 'rank': int(rank),
- 'x_cm': round(float(obj['x'] * 100.0), 1),
- 'z_cm': round(float(obj['z'] * 100.0), 1),
- 'combined_score': round(float(obj.get('score_new', np.nan)), 4),
- }
- for rank, obj in enumerate(bp_objects_to_plot, start=1)
-]
-_print_raw(json.dumps({'targets': target_results}, ensure_ascii=False, indent=2))
-
-
-# ══════════════════════════════════════════════════════
-# 4. ТОЛЬКО BP-КАРТА
-# ══════════════════════════════════════════════════════
-
-fig, ax = plt.subplots(figsize=(12, 7))
-im = ax.imshow(
- bp_map_display,
- extent=[x_grid_bp[0]*100, x_grid_bp[-1]*100, z_grid_bp[-1]*100, z_grid_bp[0]*100],
- aspect='auto',
- cmap='jet',
- vmin=0.25,
- vmax=0.95,
-)
-plt.colorbar(im, ax=ax, label='Нормированная |coherent BP|')
-ax.plot(x_tx * 100, z_tx * 100, 'r^', ms=12, label='Tx', zorder=5)
-ax.plot(x_rx * 100, z_rx * 100, 'bv', ms=12, label='Rx', zorder=5)
-
-for obj in bp_objects_to_plot:
- ax.contour(
- x_grid_bp * 100,
- z_grid_bp * 100,
- obj['region_mask'].astype(float),
- levels=[0.5],
- colors='white',
- linewidths=0.9,
- alpha=0.75,
- )
- ax.contour(
- x_grid_bp * 100,
- z_grid_bp * 100,
- obj['center_mask'].astype(float),
- levels=[0.5],
- colors='cyan',
- linewidths=0.8,
- alpha=0.85,
- )
- if obj['sidelobe_candidate']:
- ax.plot(obj['x'] * 100, obj['z'] * 100, 'x', color='yellow', ms=9,
- mew=2.0, zorder=7)
- label_text = f"{obj['index']} SL"
- text_color = 'yellow'
- else:
- ax.plot(obj['x'] * 100, obj['z'] * 100, 'wo', ms=7,
- markeredgecolor='k', mew=0.8, zorder=6)
- label_text = str(obj.get('display_index', obj['index']))
- text_color = 'white'
- ax.text(obj['x'] * 100 + 3, obj['z'] * 100, label_text,
- color=text_color, fontsize=9, weight='bold', zorder=7)
-
-if len(bp_objects_to_plot) > 0:
- ax.plot([], [], 'wo', ms=7, markeredgecolor='k', mew=0.8, label='Центроид области')
- if not BP_REMOVE_SIDELOBE_OBJECTS:
- ax.plot([], [], 'x', color='yellow', ms=9, mew=2.0, label='Sidelobe candidate')
-
-ax.axhline(MIN_DEPTH * 100, color='white', lw=1.0, ls='--', alpha=0.75)
-ax.set_xlabel('X [см]')
-ax.set_ylabel('Глубина Z [см]')
-map_title = f'Time-domain coherent BackProjection | motion={MOTION_CORRECTION_MODE}, score={BP_SCORE_MODE}, shown={len(bp_objects_to_plot)}/{bp_detected_object_count}'
-if BP_REMOVE_SIDELOBE_OBJECTS:
- map_title += ' (SL области скрыты на карте)'
-ax.set_title(map_title)
-ax.set_xlim(x_grid_bp[0] * 100, x_grid_bp[-1] * 100)
-ax.set_ylim(z_grid_bp[-1] * 100, 0)
-ax.legend(loc='lower right', fontsize=9)
-ax.grid(alpha=0.22)
-ax.invert_yaxis()
-plt.tight_layout()
-plt.show()
diff --git a/Makefile b/Makefile
index 9bd1806..e97f7c2 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,6 @@ CXX := g++
PYTHON ?= .venv/bin/python
CXXFLAGS := -std=c++20 -O2 -Wall -Wextra -Wpedantic -pthread -MMD -MP
LDFLAGS := -pthread -lrt
-ORCH_LDFLAGS := $(LDFLAGS) -lusb-1.0
BUILD_DIR := build
BIN_DIR := $(BUILD_DIR)/bin
@@ -10,7 +9,7 @@ BIN_DIR := $(BUILD_DIR)/bin
# --- CUDA for the sar3d processor (optional). Its kernels are compiled at run
# time by NVRTC and launched through the CUDA driver API, so no nvcc is needed:
# only nvrtc.h, cuda.h, libnvrtc and the NVIDIA driver's libcuda. Headers and
-# libnvrtc come from the pip nvidia-cuda-* packages in .venv (requirements-cuda.txt,
+# libnvrtc come from the pip nvidia-cuda-* packages in .venv (requirements.txt,
# x86_64) or from a system toolkit in /usr/local/cuda (Jetson). Without them
# data_processor still builds; selecting sar3d shows a CUDA warning.
CUDA_ROOTS := $(wildcard $(CURDIR)/.venv/lib/python3*/site-packages/nvidia/*) /usr/local/cuda
@@ -33,12 +32,7 @@ INCLUDES := \
-Idata_acq_and_processing/common_cpp/config/include \
-Idata_acq_and_processing/third_party \
-Idata_acq_and_processing/third_party/eigen \
- -Idata_acq_and_processing/sweep_orchestrator/include \
- -Idata_acq_and_processing/sweep_orchestrator/device_drivers/interfaces \
- -Idata_acq_and_processing/sweep_orchestrator/device_drivers/radar \
- -Idata_acq_and_processing/sweep_orchestrator/device_drivers/switches \
- -Idata_acq_and_processing/preprocessing/calibration_master/include \
- -Idata_acq_and_processing/preprocessing/reference_master/include \
+ -Idata_acq_and_processing/preprocessing/bundles/include \
-Idata_acq_and_processing/preprocessing/data_preprocessor/include \
-Idata_acq_and_processing/processing/processors/include \
-Idata_acq_and_processing/processing/data_processor/include \
@@ -49,20 +43,8 @@ COMMON_SOURCES := \
data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp \
data_acq_and_processing/common_cpp/config/src/run_config.cpp
-ORCH_SOURCES := \
- data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_lifecycle.cpp \
- data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_transport.cpp \
- data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_protocol.cpp \
- data_acq_and_processing/sweep_orchestrator/device_drivers/switches/h7992_minimal_driver.cpp \
- data_acq_and_processing/sweep_orchestrator/device_drivers/switches/hmc349a_minimal_driver.cpp \
- data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp \
- data_acq_and_processing/sweep_orchestrator/src/main.cpp
-
PREPROC_SOURCES := \
- data_acq_and_processing/preprocessing/calibration_master/src/calibration_master.cpp \
- data_acq_and_processing/preprocessing/calibration_master/src/channel_bundle_support.cpp \
- data_acq_and_processing/preprocessing/calibration_master/src/through_calibrator.cpp \
- data_acq_and_processing/preprocessing/reference_master/src/reference_master.cpp \
+ data_acq_and_processing/preprocessing/bundles/src/channel_bundle_support.cpp \
data_acq_and_processing/preprocessing/data_preprocessor/src/data_preprocessor.cpp \
data_acq_and_processing/preprocessing/data_preprocessor/src/main.cpp
@@ -87,42 +69,38 @@ PROCESSOR_SOURCES := \
# time. Built on demand (not part of `all`) for radar.model == kamil_adc.
KAMIL_COLLECTOR_DIR := data_acq_and_processing/kamil_adc_collector
# The collector now also drives the RF switches itself (switch-aware mode), so it
-# reuses the orchestrator's switch drivers and the shared run_config loader, which
+# uses the common H7992 driver and run_config loader, which
# pull in the common config/ipc/locator headers and the vendored nlohmann/json.
KAMIL_COLLECTOR_INCLUDES := \
-I$(KAMIL_COLLECTOR_DIR)/include \
-I$(KAMIL_COLLECTOR_DIR)/vendor/lcard \
-Idata_acq_and_processing/common_cpp/ipc/include \
-Idata_acq_and_processing/common_cpp/config/include \
- -Idata_acq_and_processing/sweep_orchestrator/device_drivers/interfaces \
- -Idata_acq_and_processing/sweep_orchestrator/device_drivers/switches \
+ -Idata_acq_and_processing/common_cpp/hardware/include \
-Idata_acq_and_processing/processing/locator/include \
-Idata_acq_and_processing/third_party
KAMIL_COLLECTOR_LDFLAGS := -pthread -ldl -lutil
# Collector sources, including the shared drivers/config loader it reuses. These
# are compiled into a private build/kamil/ tree (see rule below) so they never
-# collide with the orchestrator's objects, which use different compile flags.
+# collide with pipeline objects, which use different include paths.
KAMIL_COLLECTOR_SOURCES := \
$(KAMIL_COLLECTOR_DIR)/src/main.cpp \
$(KAMIL_COLLECTOR_DIR)/src/tty_protocol_writer.cpp \
$(KAMIL_COLLECTOR_DIR)/src/capture_file_writer.cpp \
data_acq_and_processing/common_cpp/config/src/run_config.cpp \
data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp \
- data_acq_and_processing/sweep_orchestrator/device_drivers/switches/h7992_minimal_driver.cpp \
- data_acq_and_processing/sweep_orchestrator/device_drivers/switches/hmc349a_minimal_driver.cpp
+ data_acq_and_processing/common_cpp/hardware/src/h7992_minimal_driver.cpp
-SWEEP_ORCH_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(ORCH_SOURCES:.cpp=.o))
PREPROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PREPROC_SOURCES:.cpp=.o))
DATA_PROCESSOR_OBJS := $(addprefix $(BUILD_DIR)/,$(COMMON_SOURCES:.cpp=.o) $(PROCESSOR_SOURCES:.cpp=.o))
KAMIL_BUILD_DIR := $(BUILD_DIR)/kamil
-KAMIL_COLLECTOR_OBJS := $(addprefix $(KAMIL_BUILD_DIR)/,$(notdir $(KAMIL_COLLECTOR_SOURCES:.cpp=.o)))
-DEPFILES := $(sort $(SWEEP_ORCH_OBJS:.o=.d) $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d) $(KAMIL_COLLECTOR_OBJS:.o=.d))
+KAMIL_COLLECTOR_OBJS := $(addprefix $(KAMIL_BUILD_DIR)/,$(KAMIL_COLLECTOR_SOURCES:.cpp=.o))
+DEPFILES := $(sort $(PREPROCESSOR_OBJS:.o=.d) $(DATA_PROCESSOR_OBJS:.o=.d) $(KAMIL_COLLECTOR_OBJS:.o=.d))
NATIVE_TEST_SOURCES := $(COMMON_SOURCES) $(LOCATOR_SOURCES) \
data_acq_and_processing/tests/native_regressions.cpp \
data_acq_and_processing/processing/data_processor/src/processing_live_config.cpp \
$(filter-out %/main.cpp,$(PREPROC_SOURCES)) \
- data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp \
$(KAMIL_COLLECTOR_DIR)/src/capture_file_writer.cpp \
$(KAMIL_COLLECTOR_DIR)/src/tty_protocol_writer.cpp
NATIVE_BUILD_DIR := $(BUILD_DIR)/native_tests
@@ -133,7 +111,6 @@ $(shell mkdir -p $(NATIVE_BUILD_DIR) && echo '$(NATIVE_CXXFLAGS)' | cmp -s - $(N
DEPFILES += $(NATIVE_TEST_OBJS:.o=.d)
TARGETS := \
- $(BIN_DIR)/sweep_orchestrator \
$(BIN_DIR)/data_preprocessor \
$(BIN_DIR)/data_processor
@@ -147,33 +124,20 @@ check-native: $(NATIVE_BUILD_DIR)/native_regressions
$(NATIVE_BUILD_DIR)/native_regressions run_config_examples/run_config_simulator.example.json
SAR3D_TEST_SOURCES := data_acq_and_processing/tests/sar3d_fixture.cpp \
+ data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp \
data_acq_and_processing/processing/processors/src/sar3d_processor.cpp \
data_acq_and_processing/processing/processors/src/sar3d_gpu.cpp
SAR3D_TEST_OBJS := $(addprefix $(NATIVE_BUILD_DIR)/,$(SAR3D_TEST_SOURCES:.cpp=.o))
-SAR3D_EMULATION_OBJ := $(NATIVE_BUILD_DIR)/data_acq_and_processing/tests/sar3d_kernel_emulation.o
-SAR3D_KERNEL_TEST_OBJ := $(NATIVE_BUILD_DIR)/data_acq_and_processing/tests/sar3d_kernel_regressions.o
-SAR3D_EMULATION_HEADERS := $(NATIVE_BUILD_DIR)/sar3d_kernels.inc $(NATIVE_BUILD_DIR)/sar3d_helpers.inc
-DEPFILES += $(SAR3D_TEST_OBJS:.o=.d) $(SAR3D_EMULATION_OBJ:.o=.d) $(SAR3D_KERNEL_TEST_OBJ:.o=.d)
+DEPFILES += $(SAR3D_TEST_OBJS:.o=.d)
-check-sar3d: $(NATIVE_BUILD_DIR)/sar3d_fixture $(NATIVE_BUILD_DIR)/sar3d_math_fixture $(NATIVE_BUILD_DIR)/sar3d_kernel_regressions
- $(NATIVE_BUILD_DIR)/sar3d_kernel_regressions
- $(PYTHON) data_acq_and_processing/tests/test_sar3d_oracle.py
+# The sar3d processor on the real CUDA backend against the notebook reference;
+# without a usable GPU the test checks the warning and skips the rest.
+check-sar3d: $(NATIVE_BUILD_DIR)/sar3d_fixture
+ SAR3D_FIXTURE="$(abspath $<)" $(PYTHON) data_acq_and_processing/tests/test_sar3d.py
$(NATIVE_BUILD_DIR)/sar3d_fixture: $(SAR3D_TEST_OBJS)
$(CXX) $(NATIVE_CXXFLAGS) $^ -o $@ $(LDFLAGS) $(SAR3D_LDFLAGS)
-$(NATIVE_BUILD_DIR)/sar3d_math_fixture: $(filter-out $(NATIVE_BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o,$(SAR3D_TEST_OBJS)) $(SAR3D_EMULATION_OBJ)
- $(CXX) $(NATIVE_CXXFLAGS) $^ -o $@ $(LDFLAGS)
-
-$(NATIVE_BUILD_DIR)/sar3d_kernel_regressions: $(SAR3D_KERNEL_TEST_OBJ) $(SAR3D_EMULATION_OBJ)
- $(CXX) $(NATIVE_CXXFLAGS) $^ -o $@ $(LDFLAGS)
-
-$(SAR3D_EMULATION_HEADERS) &: data_acq_and_processing/tests/generate_sar3d_emulation.py data_acq_and_processing/processing/processors/src/sar3d_gpu.cpp data_acq_and_processing/processing/processors/src/sar3d_gpu.hpp
- $(PYTHON) data_acq_and_processing/tests/generate_sar3d_emulation.py $(NATIVE_BUILD_DIR)
-
-$(SAR3D_EMULATION_OBJ): NATIVE_CXXFLAGS += -I$(NATIVE_BUILD_DIR)
-$(SAR3D_EMULATION_OBJ): $(SAR3D_EMULATION_HEADERS)
-
$(NATIVE_BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o: NATIVE_CXXFLAGS += $(SAR3D_CXXFLAGS)
$(NATIVE_BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o: $(SAR3D_CUDA_STAMP)
@@ -184,10 +148,6 @@ $(NATIVE_BUILD_DIR)/%.o: %.cpp $(NATIVE_FLAGS_STAMP)
@mkdir -p $(dir $@)
$(CXX) $(NATIVE_CXXFLAGS) $(INCLUDES) -I$(KAMIL_COLLECTOR_DIR)/include -c $< -o $@
-$(BIN_DIR)/sweep_orchestrator: $(SWEEP_ORCH_OBJS)
- @mkdir -p $(BIN_DIR)
- $(CXX) $(SWEEP_ORCH_OBJS) -o $@ $(ORCH_LDFLAGS)
-
$(BIN_DIR)/data_preprocessor: $(PREPROCESSOR_OBJS)
@mkdir -p $(BIN_DIR)
$(CXX) $(PREPROCESSOR_OBJS) -o $@ $(LDFLAGS)
@@ -200,12 +160,7 @@ $(BIN_DIR)/data_processor: $(DATA_PROCESSOR_OBJS) $(SAR3D_CUDA_STAMP)
$(BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o: CXXFLAGS += $(SAR3D_CXXFLAGS)
$(BUILD_DIR)/data_acq_and_processing/processing/processors/src/sar3d_gpu.o: $(SAR3D_CUDA_STAMP)
-# The collector compiles into its own build/kamil/ tree with collector-only
-# includes, so the shared driver/config sources it reuses never collide
-# with the orchestrator's objects of the same name. Basenames are unique, so a
-# vpath lets one pattern rule find every source.
-vpath %.cpp $(sort $(dir $(KAMIL_COLLECTOR_SOURCES)))
-
+# Keep source-relative paths so moving a source also replaces its dependencies.
$(KAMIL_BUILD_DIR)/%.o: %.cpp
@mkdir -p $(dir $@)
$(CXX) $(CXXFLAGS) $(KAMIL_COLLECTOR_INCLUDES) -c $< -o $@
diff --git a/README.md b/README.md
index 791bd54..0ca219a 100644
--- a/README.md
+++ b/README.md
@@ -2,25 +2,25 @@
A real-time radar acquisition and imaging system for vector-network-analyzer (VNA)
and ADC front-ends. It drives the radar through a frequency sweep, calibrates and
-preprocesses the measured S-parameters, runs a selectable imaging/detection
+preprocesses the measured S21 traces, runs a selectable imaging/detection
processor, and presents the live result in a desktop GUI and an in-process web
interface. The same application runs interactively on a workstation or unattended
as a headless Raspberry Pi appliance.
## Overview
-The system is split into a **C++ real-time pipeline** and a **Python application**
-that supervises it and handles visualization and control.
+The system uses **Python acquisition producers**, **C++ preprocessing and processing**,
+and a **Python application** for supervision, visualization and control.
The pipeline runs as three independent processes connected by lock-free POSIX
shared-memory ring buffers. Each stage advances on its own, and a slow consumer
never blocks a producer — the rings are latest-wins, so a reader that falls behind
-simply skips to the freshest frame:
+skips overwritten frames and continues from the retained tail:
-1. **Acquisition** — `sweep_orchestrator` (or a hardware-specific producer)
- configures the radar, performs the frequency sweep, and publishes raw S11/S21
- traces.
-2. **Preprocessing** — `data_preprocessor` applies through/OSL calibration,
+1. **Acquisition** — Python producers configure the radar, acquire frequency sweeps,
+ and publish raw S21 traces. Both single and multi-device LibreVNA use
+ `matrix_raw_producer`; Kamil ADC uses `kamil_adc_raw_producer` with its C++ collector.
+2. **Preprocessing** — `data_preprocessor` applies through calibration,
reference subtraction, optional frequency-domain notch filtering, and an
optional object-based MIMO correction of S21.
3. **Processing** — `data_processor` runs the selected processing mode and emits
@@ -28,9 +28,9 @@ simply skips to the freshest frame:
or 3D SAR views). Detected objects are additionally broadcast to subscribers
over a TCP locator server.
-The **Python application** (`python_app`) starts and monitors the C++ binaries,
-reads the result rings, renders the live view with pyqtgraph, and exposes the same
-controls plus a pixel-identical live plot through an embedded web UI.
+The **Python application** (`python_app`) starts and monitors the producer and C++ processes,
+reads the result rings, renders the live view with pyqtgraph, and exposes acquisition
+controls, processing settings and a copy of the live plot through an embedded web UI.
```
radar hardware
@@ -45,12 +45,13 @@ controls plus a pixel-identical live plot through an embedded web UI.
- **librevna** — single LibreVNA.
- **librevna_multi** — synchronized multi-device LibreVNA.
-- **kamil_adc** — external Kamil ADC acquisition process, with optional laser control.
+- **kamil_adc** — [Kamil ADC collector](data_acq_and_processing/kamil_adc_collector/README.md),
+ with optional laser control and [temperature monitoring](python_app/hardware_full/laser_control/monitoring/README.md).
- **mock / simulator** — driver-less mode for development without hardware.
## Processing modes
-- **pass_through** — magnitude and phase of the selected channel for each combo.
+- **pass_through** — magnitude and phase of S21 for each combo.
- **bscan** — depth-versus-sweep B-scan heatmap.
- **gpr** — coherent back-projection GPR imaging with object detection.
- **legacy_gpr** — ellipse-intersection MIMO localization (point and extended/region modes).
@@ -58,30 +59,29 @@ controls plus a pixel-identical live plot through an embedded web UI.
on an NVIDIA GPU through CUDA: front (X-Y), side (Z-Y), top (X-Z)
and oblique maximum projections of the imaged
volume. Its settings carry the notebook's parameter names; the scene window is
- fixed (X −2…2 m, height −0.5…2 m above the ground), its range extent follows
+ set by the selected antennas with a 2 m X margin and height −0.5…2 m above the ground; its range extent follows
`MIN_DEPTH`/`MAX_DEPTH`, and the voxels are cubes with edge `SAR_VOXEL_M`
(default 2 cm). The radar motion is corrected from each trace's capture
timestamps, so the sweep timing needs no parameters.
-For matrix-radar B-scans, **Next combination** advances output first, then input,
-and wraps to the first pair. The desktop fields and browser channel selector
-redraw the selected cached B-scan immediately without restarting acquisition.
-**Save Last 1000** and **Save S21 JSON** use a fixed limit of 1000 recent
-measurements from each available stage; older GUI profiles may still contain
-`save_count`, which is ignored. **Start + Record** retains its separate count for
-recording future measurements to disk.
+For B-scans on every radar model, **B-scan pair** selects a pair from the displayed
+history independently of the run combinations. **Next combination** advances output
+first, then input, and wraps to the first recorded pair; it is enabled with at least
+two pairs. Desktop and browser selectors redraw the cached B-scan immediately.
Tx/Rx antenna positions are edited in tables with switch position and X/Y/Z in
metres, on both the desktop and web interface. Coordinate and legacy-GPR medium
changes apply to the running processor and recompute its retained result. Profiles
-preserve all three coordinates. GPR/SAR views wait for a result marked with the
-current settings revision, so an older in-flight result cannot overwrite new geometry.
+preserve all three coordinates. A new table row becomes active after X is entered;
+desktop, web and profile geometry use one Python validator; C++ validates its own
+input. GPR/SAR views wait for a result marked with the current settings revision,
+so an older in-flight result cannot overwrite new geometry.
## Repository layout
| Path | Contents |
| --- | --- |
-| `data_acq_and_processing/` | C++ pipeline: `sweep_orchestrator/`, `preprocessing/`, `processing/` (including `locator/`), shared `common_cpp/` (config, IPC rings), and vendored `third_party/` (Eigen, nlohmann/json). |
+| `data_acq_and_processing/` | C++ pipeline: `preprocessing/`, `processing/` (including `locator/`), `kamil_adc_collector/`, shared `common_cpp/` (config, IPC rings, H7992 driver), and vendored `third_party/` (Eigen, nlohmann/json). |
| `python_app/` | Python application: `gui/`, `orchestration/` (process supervisor, shared-memory readers, config), `hardware_full/` (device drivers and services), `webui/`, `storage/`, `models/`, `workflows/`, `scripts/` (producers and tools), and `tests/`. |
| `run_config_examples/` | Ready-made configuration profiles, one per hardware setup. |
| `deploy/` | systemd daemon installer, and the USB update kit for the Pi and Jetson (`pack-usb.sh`, `update.sh`). |
@@ -94,7 +94,7 @@ current settings revision, so an older in-flight result cannot overwrite new geo
**Raspberry Pi 5**; hardware PWM gating for the generator sweep is Pi 5
specific (GPIO12/13/18/19, `dtoverlay=pwm-2chan`).
- A C++20 compiler and `make`; Eigen and nlohmann/json are vendored.
-- `libusb-1.0` development headers and `libatomic.so.1` (`libatomic1` on Debian/Ubuntu).
+- `libusb-1.0` runtime and `libatomic.so.1` (`libatomic1` on Debian/Ubuntu).
Python ring readers/writers use lock-free atomic loads, stores and cursor CAS
through libatomic, including memory barriers for ARM. Slot sizes must be multiples
of 8 bytes, and the complete ring mapping must not exceed 1 TiB.
@@ -102,7 +102,7 @@ current settings revision, so an older in-flight result cannot overwrite new geo
- For `sar3d`: an NVIDIA GPU with its driver (`libcuda`). The
kernels are compiled at run time by NVRTC, so there is no nvcc step; `make`
needs only `nvrtc.h`, `cuda.h` and `libnvrtc`, taken from the pip
- `nvidia-cuda-*` packages in `.venv` (`requirements-cuda.txt`, x86_64) or from
+ `nvidia-cuda-*` packages in `.venv` (`requirements.txt`, x86_64) or from
a system toolkit in `/usr/local/cuda` (Jetson).
Without these dependencies or a usable GPU, SAR3D shows a warning above the
plots and does not calculate an image. Other processing modes remain available.
@@ -115,57 +115,50 @@ From the repository root:
python3 -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r requirements.txt
-make # build the C++ binaries into build/bin/
+make # data_preprocessor and data_processor in build/bin/
+make kamil_adc_collector # also required for Kamil ADC acquisition
```
`make` builds the sar3d GPU code when it finds CUDA (see Requirements) and
prints a note when it does not. After installing the CUDA requirements into an
existing venv, run `make` again: it notices the change and rebuilds that part.
-For SAR3D on x86_64, install the optional packages with
-`.venv/bin/python -m pip install -r requirements-cuda.txt`, then run `make`.
+CUDA 13 is installed from `requirements.txt` on x86_64. For GPUs older than
+sm_75, install NVRTC 12 outside `.venv` and build with
+`make BUILD_DIR=/tmp/build-nvrtc12 CUDA_ROOTS="
/nvidia/cuda_nvrtc /nvidia/cuda_runtime"`.
+A separate build directory keeps the two CUDA builds independent.
-Run the desktop GUI:
+Run the desktop GUI through the launcher for build and single-instance handling:
```bash
-.venv/bin/python -m python_app.gui.main
-# or, with the build step and single-instance handling:
-./start.sh
+./start.sh --profile run_config.json # explicit local mock profile
+# or choose a hardware profile from run_config_examples/
```
-`start.sh` supports `--headless` (offscreen, auto-start — for unattended Pi use),
-`--profile PATH`, `--producer-only`, `--skip-build`, and `--clean-shm`; see
-`./start.sh --help`. The acquisition device (LibreVNA, Kamil ADC, …) is detected
-automatically from the active config's `radar.model` — there is no per-device flag.
+`start.sh` also supports `--headless` (offscreen, auto-start), `--auto-start`,
+`--producer-only`, `--skip-build`, `--build-only`, and `--clean-shm`; see
+`./start.sh --help`. Pass `--profile PATH` so launcher provisioning and the GUI
+use the same profile. The producer is selected from `radar.model`, with dataset
+replay taking precedence. There is no per-device flag. Provision dependencies
+before unattended use: headless startup does not install missing packages.
+
+For development, `.venv/bin/python -m python_app.gui.main` starts the GUI directly,
+without the launcher's build step or single-instance lock.
## Jetson (aarch64) bring-up
-Ubuntu 22.04 on Jetson has glibc 2.35, and PyQt6 6.8+ ships only
-`manylinux_2_39` aarch64 wheels — `requirements.txt` therefore pins PyQt6 to
-`<6.8` on aarch64 (6.7.1 is the last release with a compatible wheel). With
-that pin the stock `./start.sh` provisioning works; the extra steps are only
-for the CUDA-backed `sar3d` processor:
+`requirements.txt` pins PyQt6 below 6.8 on aarch64 for the older glibc used by
+Jetson images. CUDA comes from JetPack in `/usr/local/cuda`; pip CUDA packages
+are installed only on x86_64. Use the same build commands and launcher as above.
+For dataset replay, first copy the dataset locally and update
+`radar.replay.dataset_dir` in the profile; example paths are machine-specific:
```bash
-# 1. copy the replay dataset (for dataset-replay profiles) and point
-# radar.replay.dataset_dir in the profile at the local copy — the path in
-# run_config_examples/*.json is machine-specific.
-
-# 2. normal launch; make takes the JetPack CUDA from /usr/local/cuda
-# (requirements.txt installs no pip nvidia-* packages on aarch64) and the
-# driver's /usr/lib/aarch64-linux-gnu/nvidia/libcuda.so.1:
./start.sh --profile run_config_examples/run_config_replay_vna.example.json
```
-Ubuntu 22.04 ships Python 3.10, and the host parses the radar stream in Python:
-the same parsing code ran 1.6 times slower on 3.10 than on 3.13 (the Pi's version)
-in a benchmark. Use Python 3.13 for the venv, installed with uv so the system
-`python3` stays untouched (apt depends on it):
-
-```bash
-curl -LsSf https://astral.sh/uv/install.sh | sh # then open a new terminal
-uv python install 3.13
-bash radar_update/update.sh --python 3.13 # see Deployment
-```
+The USB updater can create a fresh venv with an already installed interpreter,
+for example `bash radar_update/update.sh --python 3.13`. The system Python does
+not need to be replaced.
## Running without hardware (emulation)
@@ -180,31 +173,38 @@ along the antenna array and between depths 3.25–4.25 m over an eight-second cy
Each S21 uses the actual transmitter–target–receiver distance from the configured
XYZ coordinates; coincident antennas produce identical signals. All combinations
share the same target position and measured capture window, with collections paced
-at 50 ms intervals. B-scan's default range is 0–8 m; stored profiles retain their
-chosen range. The simulator models a stationary radar, so radar speed should be zero.
+at 50 ms intervals. The root mock profile explicitly selects a 0–8 m B-scan
+range. Both shipped mock profiles use a 0.445 m vertical antenna baseline
+(Rx1/Rx3 and, for the four-output matrix, Tx2/Tx3), avoiding coincident pairs.
+The general defaults are 0.824–1.0 m; stored profiles retain their range. The
+simulator models a stationary radar, so radar speed should be zero.
The scene uses the antenna positions from its startup profile; live geometry edits
change the reconstruction of captured sweeps, whose samples remain unchanged.
The preprocessor still requires S21 calibration/reference sets. Start the GUI:
```bash
-./start.sh
+./start.sh --profile run_config.json
```
-In **Preprocessing Panel**, use **Create neutral sets**, then **Start**. This makes
+Open **Preprocessing**, use **Create Neutral S21 Sets**, then **Start**. This makes
identity calibration + zero reference, preserving the simulated S21 samples.
**Dataset replay (recorded scene).** When
`radar.replay.dataset_dir` is set in the profile, the supervisor launches
`python_app.scripts.replay_raw_producer` instead of the normal producer: it
-replays recorded snapshots into the raw ring at the recorded cadence, looping
-forever with a monotonic timeline. Pair it with NEUTRAL preprocess sets so the
-already-preprocessed recording passes through the preprocessor bit-exactly.
-A ready profile for the VNA_AlGo tank005 dataset is provided:
+replays the collection directories in that folder into the raw ring. Point the
+path at a saved stage such as `dataset/preprocessed`, containing
+`NNNN_idK_ns/` directories with `meta.json` and `i*_o*_{freq,s21}.npy`.
+Replay follows the recorded cadence scaled by `radar.replay.rate_scale`;
+`radar.replay.loop` defaults to true. Trace windows preserve the recorded spacing
+for SAR, even with accelerated replay. Each collection starts at its actual publication
+time for acquisition identity and retains the original duration for legacy GPR.
+For an already-preprocessed recording, select neutral through/reference sets
+and disable notch and object correction to preserve the recorded S21 values.
+A ready profile for the VNA_AlGo tank005 dataset is provided. Its neutral sets are
+created once with the **Create Neutral S21 Sets** button of the preprocessing panel:
```bash
-# one-time: neutral sets for this profile (or use the GUI button)
-.venv/bin/python -m python_app.scripts.create_neutral_preprocess_sets \
- --config run_config_examples/run_config_replay_vna.example.json
# interactive GUI (mode sar3d is pre-selected by the profile's gui section):
./start.sh --profile run_config_examples/run_config_replay_vna.example.json
```
@@ -214,77 +214,195 @@ a faster preview matters more than spatial resolution. Processing failures appea
above the plots and a failed single capture
finishes with its explanation while retaining the acquired data.
-The web interface is served in-process at `http://:8080`. It mirrors the live
-plot and offers Start / Stop / Capture controls and the active processor settings.
+## Web interface
-Manual dataset and JSON export keep the last 1000 available collections from each
-stage independently, including raw or preprocessed sweeps without a result.
-Streaming recording keeps every collection received after arming until the requested
-result count or Stop; Stop and closing the window flush accepted data. Acquisition
-rings use latest-wins delivery, so disk recording cannot recover sweeps overwritten
-before the GUI receives them. NumPy datasets preserve both S-parameters, capture
+The embedded server listens at `http://:8080`; set
+`RADAR_SYSTEM_WEBUI_PORT` to use another port. It is available in desktop and
+headless mode. Browser actions are:
+
+- **Start**, **Single Capture**, **Stop**, **Tmp Reference** and **Remove Last Measurement**;
+- load a profile from `run_configs/*.json` using **Load**;
+- **Save Last 1000** and **Start + Record** with a path, optional name and recording count;
+- edit processor settings and antenna tables with **Apply**, or clear history with **Reset history**;
+- select a **B-scan pair** or advance with **Next combination**.
+
+Copy profiles from `run_config_examples/` into `run_configs/` to offer them in the
+browser. JSON export and calibration dialogs are desktop actions. PNG capture
+runs only while a browser is connected and the plot has changed, including zoom
+and resize, at most five times per second. Each client retains the latest frame,
+status and settings independently; reconnecting refreshes settings, and **Apply**
+sends only edited fields.
+
+## Data and preprocessing
+
+### Snapshots and recording
+
+**Save Last 1000** and **Save S21 JSON** keep up to 1000 available collections from
+each stage independently, including raw or preprocessed sweeps without a result.
+The limit is fixed; legacy profile `save_count` values are ignored. Saving
+keeps an active disk recording running.
+Without raw/preprocessed taps, result snapshots remain available, but S21 JSON
+requires retained traces.
+**Start + Record to Disk** (**Start + Record** in the browser) has a separate count
+for future results. It keeps collections received after arming until that count,
+Stop, or exhaustion of the bounded writer queue. If storage falls behind,
+recording stops accepting data, drains the queue and final chunk, and reports the limit.
+Stop and closing the window also flush accepted data without blocking the GUI.
+Pausing acquisition for a capture or history action ends an active recording:
+the log reports the accepted count, requested count and stop reason before draining
+the writer. Latest-wins rings cannot recover sweeps overwritten before the GUI
+receives them. NumPy datasets preserve S21, capture
windows, every supported result payload, and processing time. Failed exports remove
their staging files, leaving existing datasets intact. Companion profiles retain
the radar, antenna layout and preprocessing used for acquisition, including when
-another profile is loaded after Stop.
+another profile is loaded after Stop. Dataset name prefixes also describe the acquisition;
+a manual save is cancelled if acquisition is restarted during its final history drain.
+
+Each NumPy dataset includes `processing_settings.json`, mapping result revisions to
+the live settings used for processing. Results retain their revision, capture window,
+and actual `speed_m_s` (including speed supplied by the locator). Settings
+changed while acquisition is stopped are recorded for the reprocessed results as well.
+**Remove Last Measurement** uses capture order and removes matching collections
+from every stage; the processor suppresses that sweep if it is still pending.
+**Clear Runtime History** (**Reset history** in the browser) clears all retained
+stages and rejects late results from before the cutoff. These actions leave saved
+files intact. A sweep is identified by `(collection_id, capture_start_ns)`; all
+stages preserve the producer's capture window. `monotonic_ns` is diagnostic only.
+
+**Save S21 JSON** can add exports to an existing NumPy dataset. Existing JSON files
+are never overwritten; missing raw/preprocessed counterparts produce empty arrays,
+and stages pair only by the exact acquisition key. Its `vna_config_profile.json` companion
+leaves the NumPy dataset's `config_profile.json` intact. A streaming write failure
+keeps all collections already published; only the incomplete collection is discarded.
+The offline snapshot converter uses this same exporter. Legacy snapshots without
+capture windows retain repeated IDs through one-to-one time/index matching;
+processing completion timestamps are not compared with acquisition timestamps.
+
+### Calibration and reference sets
Calibration and reference sets keep their trace arrays and metadata together in one
-atomically replaced NPZ archive, with a readable JSON sidecar. Earlier NPZ/JSON pairs
+atomically replaced NPZ archive, with a readable JSON sidecar. Both files and their
+directory entries are synced before successful completion. Existing file permissions
+are retained; new files and directories respect the process umask. Earlier NPZ/JSON pairs
remain supported. Invalid trace shapes, duplicate combinations and non-finite
calibration/reference values are rejected before a saved set is replaced.
Median captures also require identical frequency axes and channel coverage.
-In **Preprocessing Panel**, create or select the ordinary S21 calibration and
-empty-scene reference first. **Create…** in the **Object Calibration** row opens a separate step:
-take fresh sweeps, then enter the measured reflector centre X/Y/Z in metres.
-Height above ground, radar pitch and sphere radius follow `mimo_calibration.py`;
-several reflector positions may contribute to one calibration. The calculation
-shows each pair's phase at the target before/after correction and its coherence.
-The selected reference is already subtracted, and the previous object correction
-is disabled while these sweeps are acquired. Saved object corrections apply on the
-next **Start**, after notch filtering and before any processor; **Off** disables
-them. Changing antenna geometry disables an incompatible correction and reloads
-acquisition without restarting the application. **Import…** in that row
-accepts the example's `cal_*.npy` / `cal_meta.json` format; the example and GUI share
-one numerical implementation.
+Open **Preprocessing** to create or select the ordinary S21 calibration and
+empty-scene reference. The processing order is through calibration → calibrated
+reference subtraction → notch → object correction → the selected processor.
-Dataset replay restores both recorded S-parameters and orders long recordings by
-their numeric index; older S21-only datasets still replay with zero S11. Offline
-JSON conversion and the GUI share the same sweep-matching code.
+### Object calibration
+
+**Create…** in the **Object Calibration** row opens a separate dialog. Use
+**Capture data** to take fresh preprocessed sweeps, then enter the measured
+reflector centre X/Y/Z in metres and use **Calculate and save**.
+Coordinates follow the notebook: X across the array, Y above the ground, Z along
+the ground.
+Height above ground, radar pitch and sphere radius follow `MIMO_calibration.ipynb`;
+several reflector positions may contribute to one calibration. The only diagnostic
+plot shows the phase of each antenna pair's contribution at the target point,
+before and after correction, as in cell 5 of `MIMO_calibration.ipynb`: Tx rows,
+Rx columns, phase relative to the complex sum of each map, with cell values and
+standard deviation in degrees on a cyclic ±180° scale.
+With partial antenna geometry, only the specified Tx × Rx pairs contribute to
+the calculation and phase maps. Acquisition still retains every physical stage;
+pairs outside that geometry receive unit correction and appear in `unchanged_combos`.
+The selected reference is already subtracted, and the previous object correction
+is disabled while these sweeps are acquired. A capture owns an exclusive pipeline
+pause until it finishes or is cancelled, including during automatic-restart backoff.
+An explicit **Stop** cancels automatic resumption.
+
+Object corrections use the same NPZ collection format as other preprocessing sets:
+S21 holds each pair's multiplier on the captured frequency grid. Metadata retain
+the capture radar key, antenna geometry, through-set name and content hash, target
+positions, estimation settings and phase diagnostics. Saving uses the capture's
+configuration even if the current profile changes during calculation. The next
+**Start** checks the selected through set's S21 and frequency arrays, geometry and
+coverage of the requested combinations before applying the correction after notch
+filtering and before any processor. **Off** disables it. Changing antenna geometry
+disables an incompatible correction and reloads acquisition without restarting
+the application. Changing relative permittivity only updates processing settings.
+
+Legacy `cal_*.npy` / `cal_meta.json` folders are not application calibration sets.
+Capture a set through **Create…** to use object correction; the legacy files on
+disk are left intact. Ordinary through/reference NPZ sets need no conversion.
+
+Older NPZ sets and snapshots remain readable: extra S11 arrays are ignored;
+new trace files contain only frequency and S21.
+
+## Runtime interfaces
+
+Tap rings are optional: an empty `rings.raw_tap.name` or
+`rings.preprocessed_tap.name` disables that copy and its GUI history. Core processing
+and single capture still work without taps; object calibration needs the preprocessed
+tap. All producers share raw publication and hardware retry handling.
+
+Raw and preprocessed IPC use **RAW3** and **PRP3** respectively: each trace contains
+float32 frequencies followed by complex64 S21 (12 bytes per point). The complete
+frame requires collection and per-trace capture windows; its size is `44 + combos * (28 + 12 * points)` bytes.
+Older RAW2/PPR2 bundles are incompatible and are rebuilt from NPZ sets at Start;
+update Python and C++ together. Results use RSL3; older RSL2 frames are rejected.
+
+RF switches use H7992 with 1–4 positions and two GPIO control lines. Profiles use
+`driver_mode`, GPIO settings and the position count. An old `driver: "h7992"` is accepted;
+other explicit driver values or enabled inversion are rejected before GPIO opens.
+Two positions use the existing H7992 mapping `(A,B) = (0,0), (0,1)`; physical
+wiring and channel numbers follow the profile.
+
+Single-device LibreVNA acquires the configured `run.combos` in order. Multi-device
+acquisition keeps the full matrix: SAR3D derives intra-sweep stage timing from
+the complete list of outputs sharing a capture window.
## Configuration
-The active profile is `run_config.json`. It selects the radar model and sweep, the
-switch matrix and acquisition combos, the preprocessing calibration/reference
-assets, the GPR geometry, and the shared-memory ring sizes. Profiles for each
-hardware setup are provided in `run_config_examples/` — copy one to
-`run_config.json`, or pass it with `--profile`, to use it.
+A profile selects the radar model and sweep, switches and acquisition combos,
+preprocessing sets, antenna geometry, ring sizes and GUI settings. The GUI loads
+`RADAR_SYSTEM_PROFILE` (set by `start.sh --profile`), otherwise the last profile in
+`python_app/runtime/gui_session_state.json`, otherwise root `run_config.json`.
+If a selected external profile cannot be loaded, it reports the error and falls
+back to the root profile. **Load Config** and **Save Config** manage desktop profiles.
+
+Hardware examples are in `run_config_examples/`. The root `run_config.json` is
+intentionally mock; select a hardware profile explicitly on a real device. A
+change of model, serials or sweep parameters changes the `radar_key` used to find
+calibration/reference sets; select or capture matching sets before **Start**.
## Deployment
On a Raspberry Pi, `deploy/install-daemon.sh` installs a systemd service that runs
-the application headless and restarts it on failure. A single-instance lock ensures
-the interactive GUI and the daemon never run at the same time, since they share the
-radar, the shared-memory rings, and the locator port.
+the application through `start.sh --headless --skip-build` and restarts it on
+failure. The launcher holds a single-instance lock for the radar, rings and locator
+port; an interactive launch first asks systemd to stop an active daemon.
**Updating a device from a USB flash drive.** On the development machine
-`deploy/pack-usb.sh` writes the host part of the last commit (no venv, build, data
-or local configs) and the update script into `radar_update/` on the flash drive.
+`deploy/pack-usb.sh /path/to/drive` writes the host part of the **last commit**
+and its update script into `radar_update/` on the flash drive. Uncommitted changes,
+venv, build outputs, recorded data and local configs are excluded.
On the Pi or Jetson, close the program and run, from the drive's folder:
```bash
bash radar_update/update.sh # or: --python 3.13 for a fresh venv
```
-It installs the newest archive into `~/Documents/radar_system` (or `RADAR_DIR`);
-the current version is renamed to `radar_system`, the next free number. Kept from
-it: `run_config.json`, profiles in `run_config_examples/` changed or added on the
-device (the release version of a changed one lands next to it as `.new`),
-`python_app/runtime/` without logs, `python_app/data*` and any other top-level
-file or folder that is not part of the release, as hard links. The venv is copied
-independently so package updates cannot modify the rollback environment (or a new
-one is built with `--python`). It builds the C++ pipeline and ADC collector and restarts
-`radar.service` if it was running. To go back:
-`rm -rf radar_system && mv radar_system radar_system`.
+Run the updater as the normal device user. It installs the newest archive into
+`~/Documents/radar_system` (or `RADAR_DIR`), renaming the current version to the
+next free `radar_system`. It preserves:
+
+- `run_config.json` and changed/added `run_config_examples/*.json` by independent
+ copy; a conflicting release profile is placed beside the device profile as `.new`;
+- `python_app/runtime/` by copy, excluding logs and `*.pids`;
+- `python_app/data*` and extra non-hidden top-level entries absent from the release
+ by hard links, excluding `build`, `graphify-out`, `device_firmware`, `docs` and `RELEASE`;
+- the venv by independent copy, or a fresh one when `--python` is supplied.
+
+Saved data is written through replacement or new files, preserving the backup's
+hard links. `RELEASE` gets a new inode. The updater builds the C++ pipeline and ADC
+collector, then restarts `radar.service` only if it was running before the update.
+Keep the device's real hardware profile: the release's root profile is mock.
+
+For rollback, stop `radar.service` and close the GUI, rename the new installation
+aside to preserve measurements taken since the update, then rename the chosen
+backup to `radar_system`. Restart the service if it is normally used.
## Tests
@@ -302,10 +420,93 @@ also exercise mutated frames, ring concurrency, locator input and ADC spool reco
Firmware host tests check cart timer rollover and SBUS packing; they do not validate
physical timing. Real devices, GPIO and the CUDA backend need their own hardware run.
+`check-sar3d` runs the real CUDA processor (`data_acq_and_processing/tests/test_sar3d.py`).
+It compares it with `sar3d_reference.npz`: compact float32/complex64 inputs, the three
+maximum projections of the notebook's volume, and its joint peak/level for four
+compensation/normalization and timing cases. The expected values come from unchanged
+functions in cells 0 and 4 of the original notebook. The same test checks the published
+status for invalid input, a result too large for the ring slot, an empty and a very weak
+signal. Without a usable GPU it checks only the CUDA warning and skips the rest; the
+notebook itself is not needed to run it. To regenerate the reference, pass the
+original notebook (it is kept outside the repository):
+
+```bash
+.venv/bin/python data_acq_and_processing/tests/generate_sar3d_reference.py \
+ --notebook /path/to/Multi_antenna_3D_clean_v1.ipynb
+```
+
+The archive records cell-source SHA256 hashes and NumPy/SciPy versions. Another build
+can be checked with `make check-sar3d BUILD_DIR=/path/to/build` or by running
+`test_sar3d.py` with `SAR3D_FIXTURE=/path/to/build/native_tests/sar3d_fixture`.
+For older GPUs, pass the same `BUILD_DIR` and NVRTC 12 `CUDA_ROOTS` used to build.
+
+For development, install `pyright` into the venv, then run
+`.venv/bin/pyright python_app` to check all Python modules and tests.
+
+## Application architecture
+
Invalid device/switch settings and undersized raw/preprocessed slots fail before
acquisition starts. Oversized processed results report the required slot size in
the log; switching to a mode that fits resumes processing without a restart.
+`AppWindow` assembles controllers and shuts them down. `WindowView` owns layout
+and typed sections; controllers receive concrete dependencies, without GUI mixins
+or duplicate window state. `ProfileController` owns startup selection, session
+state and profile persistence. `RadarSettingsController` applies device limits,
+while `LiveSettingsController` publishes revisions and updates plots.
+
+GUI scalar settings use dataclass metadata in `models/gui_profile_schema.py`:
+profile validation, desktop values, live processing fields, processing log summaries
+and web forms share those bindings. Hardware fields and geometry have their own
+typed mappings and validators. Section builders return typed widget groups; their
+controllers connect signals directly, with no string table of action callbacks.
+`gui/controllers/config_controller.py` reads and applies scalar metadata without owning
+the window or acquisition processes. Web batches roll back together when validation
+or live-file publication fails; desktop settings also revert to the last publication.
+Profile loading blocks editor signals until all settings are populated.
+
+`HistoryController` owns retained acquisitions; `PlotController` selects the view
+and supplies independent B-scan, GPR, trace and SAR renderers with typed settings.
+Renderers own their plots and display caches, without access to the acquisition
+processes. Stopped B-scan replay uses the last run's ring, matches exact acquisition
+keys and the requested settings revision, and cancels when that run changes. With
+no retained preprocessed sweeps (including disabled taps), the processor recomputes
+its own retained history. Capture previews preserve the selected processing view.
+`PipelineController` owns processes, ring readers, polling/recovery, Single Capture
+and replay. Its pause prevents recovery from starting a producer over a hardware
+capture; Stop or Close cancels any pending resumption, including during a drain.
+Validation rejects Start before stopping existing processes. Pauses resume only
+continuous acquisition, retry failed resumption with the usual backoff, and release
+ownership before presenting capture errors. GPIO errors are logged without dialogs.
+Rendering failures leave acquisition and the locator running and log each cause once;
+repeated ring-read failures retain their separate recovery and stop policy.
+`PreprocessController` owns calibration/reference sessions and asset selection.
+One `SequentialCaptureSession` handles both current settings and a list of sweep
+variants; a single variant adds no extra acquisition or reconfiguration. Multi-device
+collection always captures one complete synchronized cycle. The native
+`DataPreprocessor` owns all three bundles in `preprocessing/bundles`: through,
+through-calibrated reference and object correction, applied in that order with notch
+filtering before object correction.
+Object capture checks both acquisition processes and releases its pause only after
+completion or cancellation; losing the producer reports a failure immediately.
+`SnapshotController` coordinates manual exports, paused Clear/Remove operations
+and Start + Record.
+History deletion is retained before publishing it to the processor, so a failed
+settings write can be retried without restoring deleted sweeps. `RecordingController`
+owns a bounded writer session; its disk thread receives data and storage only.
+A processor restart during Start + Record does not finish the newly armed session.
+Temporary references and neutral sets clear retained processing history without
+restarting the locator while the pipeline is paused.
+
+`WebBridge` queues browser actions onto Qt and exposes immutable status/frame
+snapshots. Clear and Remove use separate action endpoints; live settings only edit
+settings. Trace display options (including its Y range) do not publish processing
+revisions. A timeout cancels queued actions; an action already running reports that
+state without inviting a duplicate request. The GPIO controller invokes the same
+reference-capture action as desktop and web, and ignores queued presses after Close.
+`LogController` batches worker logs and returns web-action errors to the browser
+without opening a desktop modal. Stop/Close also cancels delayed launcher startup.
+
GUI profiles, run configs, session state and live processing settings use shared
atomic writers with unique temporary files. Profiles and run/session settings sync
their file and directory; frequent live settings avoid extra disk syncs. A failed
diff --git a/data_acq_and_processing/common_cpp/config/include/pipeline_runtime.hpp b/data_acq_and_processing/common_cpp/config/include/pipeline_runtime.hpp
new file mode 100644
index 0000000..e2ff966
--- /dev/null
+++ b/data_acq_and_processing/common_cpp/config/include/pipeline_runtime.hpp
@@ -0,0 +1,53 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "run_config.hpp"
+
+namespace radar::runtime {
+
+inline std::atomic stop_requested{false};
+
+inline auto initialize(int argc, char** argv) -> config::RunConfig {
+ const auto stop = [](int) { stop_requested.store(true, std::memory_order_relaxed); };
+ std::signal(SIGINT, stop);
+ std::signal(SIGTERM, stop);
+ std::signal(SIGPIPE, SIG_IGN);
+ std::string path = "run_config.json";
+ for (int index = 1; index + 1 < argc; ++index) {
+ if (std::string(argv[index]) == "--config") { path = argv[++index]; }
+ }
+ return config::load_run_config(path);
+}
+
+class FatalPipelineError : public std::runtime_error {
+ public:
+ using std::runtime_error::runtime_error;
+};
+
+// Drop isolated malformed collections; make persistent failures conspicuous.
+class CollectionErrors {
+ public:
+ void progress() { consecutive_ = 0; }
+ void report(const char* worker, const std::exception& error) {
+ ++total_;
+ if (++consecutive_ % 500 == 0) {
+ std::cerr << worker << ": " << consecutive_
+ << " consecutive failures — likely a permanent configuration error: "
+ << error.what() << '\n';
+ } else if (total_ % 100 == 1) {
+ std::cerr << worker << ": dropped collection after error (count="
+ << total_ << "): " << error.what() << '\n';
+ }
+ }
+ private:
+ std::uint64_t total_ = 0;
+ std::uint64_t consecutive_ = 0;
+};
+
+} // namespace radar::runtime
diff --git a/data_acq_and_processing/common_cpp/config/include/run_config.hpp b/data_acq_and_processing/common_cpp/config/include/run_config.hpp
index 19ca67c..0d79507 100644
--- a/data_acq_and_processing/common_cpp/config/include/run_config.hpp
+++ b/data_acq_and_processing/common_cpp/config/include/run_config.hpp
@@ -14,40 +14,15 @@ enum class DriverMode {
Native,
};
-enum class SwitchDriverKind {
- H7992,
- HMC349A,
-};
-
-struct RadarSweepSettings {
- // Frequency sweep bounds and acquisition settings.
- float start_hz = 1'000'000.0F;
- float stop_hz = 6'000'000'000.0F;
- std::uint32_t points = 501;
- float if_bandwidth_hz = 1'000.0F;
- float power_dbm = -10.0F;
-};
-
-struct RadarConfig {
- // Radar device identity and runtime mode.
- std::string model = "librevna";
- std::string serial{};
- DriverMode driver_mode = DriverMode::Mock;
- RadarSweepSettings sweep{};
-};
-
struct SwitchConfig {
// One RF switch attached to a specific radar port.
std::string name{};
DriverMode driver_mode = DriverMode::Mock;
- SwitchDriverKind driver_kind = SwitchDriverKind::H7992;
- std::uint32_t radar_port = 0;
std::uint32_t positions = 4;
std::uint32_t default_position = 0;
std::string gpio_chip = "/dev/gpiochip0";
std::int32_t pin_a = 17;
std::int32_t pin_b = 27;
- bool invert_logic = false;
};
struct RingEndpointConfig {
@@ -59,22 +34,18 @@ struct RingEndpointConfig {
struct RingsConfig {
RingEndpointConfig raw{.name = "/radar_raw"};
- RingEndpointConfig raw_tap{.name = "/radar_raw_tap"};
RingEndpointConfig preprocessed{.name = "/radar_preprocessed"};
RingEndpointConfig preprocessed_tap{.name = "/radar_preprocessed_tap"};
RingEndpointConfig results{.name = "/radar_results"};
};
struct RuntimeConfig {
- // Runtime pacing and behavior flags for C++ workers.
- std::uint32_t settling_ms = 0;
+ // Runtime pacing and live processing configuration.
std::uint32_t idle_sleep_ms = 2;
- bool continuous = true;
std::string processing_live_config_path = "python_app/runtime/processing_live.json";
};
struct PreprocessAssetConfig {
- std::string set_name{};
std::string bundle_path{};
};
@@ -84,17 +55,6 @@ struct S21PreprocessConfig {
PreprocessAssetConfig object_calibration{};
};
-struct S11CalibrationPreprocessConfig {
- PreprocessAssetConfig open{};
- PreprocessAssetConfig short_standard{};
- PreprocessAssetConfig load{};
-};
-
-struct S11PreprocessConfig {
- S11CalibrationPreprocessConfig calibration{};
- PreprocessAssetConfig reference{};
-};
-
struct PreprocessNotchBandConfig {
float low_hz = 0.0F;
float high_hz = 0.0F;
@@ -110,7 +70,6 @@ struct PreprocessNotchConfig {
struct PreprocessConfig {
// Channel-specific preprocessing assets selected by Python GUI layer.
S21PreprocessConfig s21{};
- S11PreprocessConfig s11{};
PreprocessNotchConfig notch{};
};
@@ -140,7 +99,7 @@ struct GprConfig {
struct RunConfig {
// Single configuration object shared by all C++ processes.
- RadarConfig radar{};
+ std::string radar_model = "librevna";
SwitchConfig input_switch{};
SwitchConfig output_switch{};
RingsConfig rings{};
diff --git a/data_acq_and_processing/common_cpp/config/src/run_config.cpp b/data_acq_and_processing/common_cpp/config/src/run_config.cpp
index 064919b..674295d 100644
--- a/data_acq_and_processing/common_cpp/config/src/run_config.cpp
+++ b/data_acq_and_processing/common_cpp/config/src/run_config.cpp
@@ -160,7 +160,6 @@ using Json = nlohmann::json;
const auto* asset_obj = as_object(required_field(object, key), asset_context);
PreprocessAssetConfig asset{};
- asset.set_name = optional_string(*asset_obj, "set_name", "");
asset.bundle_path = optional_string(*asset_obj, "bundle_path", "");
return asset;
}
@@ -242,33 +241,7 @@ using Json = nlohmann::json;
throw std::runtime_error("Unsupported driver_mode value: " + value);
}
-[[nodiscard]] auto parse_switch_driver_kind(const std::string& value) -> SwitchDriverKind {
- if (value == "h7992") {
- return SwitchDriverKind::H7992;
- }
- if (value == "hmc349a") {
- return SwitchDriverKind::HMC349A;
- }
- throw std::runtime_error("Unsupported switch driver value: " + value);
-}
-
-void validate_radar_sweep(const RadarSweepSettings& sweep) {
- if (sweep.start_hz < 0.0F || sweep.stop_hz < 0.0F || !(sweep.if_bandwidth_hz > 0.0F)) {
- throw std::runtime_error("radar.sweep frequencies must be non-negative and IF bandwidth must be > 0");
- }
- if (sweep.points == 0U) {
- throw std::runtime_error("radar.sweep.points must be > 0");
- }
- if (sweep.stop_hz < sweep.start_hz) {
- throw std::runtime_error("radar.sweep.stop_hz must be >= start_hz");
- }
-}
-
void validate_switch_config(const SwitchConfig& config) {
- if (config.radar_port != 1U && config.radar_port != 2U) {
- throw std::runtime_error("Switch radar_port must be 1 or 2 for " + config.name);
- }
-
if (config.positions == 0U) {
throw std::runtime_error("Switch positions must be > 0 for " + config.name);
}
@@ -284,21 +257,11 @@ void validate_switch_config(const SwitchConfig& config) {
throw std::runtime_error("gpio_chip is required for native switch mode: " + config.name);
}
- if (config.driver_kind == SwitchDriverKind::H7992) {
- if (config.positions > 4U) {
- throw std::runtime_error("Native H7992 switch driver supports positions <= 4 for " + config.name);
- }
- if (config.pin_a < 0 || config.pin_b < 0 || config.pin_a == config.pin_b) {
- throw std::runtime_error("pin_a/pin_b are invalid for native H7992 mode: " + config.name);
- }
- return;
+ if (config.positions > 4U) {
+ throw std::runtime_error("Native H7992 switch driver supports positions <= 4 for " + config.name);
}
-
- if (config.positions > 2U) {
- throw std::runtime_error("Native HMC349A switch driver supports positions <= 2 for " + config.name);
- }
- if (config.pin_a < 0) {
- throw std::runtime_error("pin_a (control) is required for native HMC349A mode: " + config.name);
+ if (config.pin_a < 0 || config.pin_b < 0 || config.pin_a == config.pin_b) {
+ throw std::runtime_error("pin_a/pin_b are invalid for native H7992 mode: " + config.name);
}
}
@@ -350,22 +313,24 @@ void validate_combo_key(const ipc::ComboKey& key, const RunConfig& config) {
[[nodiscard]] auto parse_switch_config(
const Json& object,
- const std::string& default_name,
- std::uint32_t default_radar_port
+ const std::string& default_name
) -> SwitchConfig {
const auto* switch_obj = as_object(object, default_name);
SwitchConfig config{};
config.name = optional_string(*switch_obj, "name", default_name);
config.driver_mode = parse_driver_mode(optional_string(*switch_obj, "driver_mode", "mock"));
- config.driver_kind = parse_switch_driver_kind(optional_string(*switch_obj, "driver", "h7992"));
- config.radar_port = optional_u32(*switch_obj, "radar_port", default_radar_port);
+ if (optional_string(*switch_obj, "driver", "h7992") != "h7992") {
+ throw std::runtime_error("Only H7992 switches are supported; check GPIO wiring before updating " + default_name);
+ }
+ if (optional_bool(*switch_obj, "invert_logic", false)) {
+ throw std::runtime_error("Switch invert_logic is unsupported for H7992: " + default_name);
+ }
config.positions = optional_u32(*switch_obj, "positions", 4);
config.default_position = optional_u32(*switch_obj, "default_position", 0);
config.gpio_chip = optional_string(*switch_obj, "gpio_chip", "/dev/gpiochip0");
config.pin_a = optional_i32(*switch_obj, "pin_a", 17);
config.pin_b = optional_i32(*switch_obj, "pin_b", 27);
- config.invert_logic = optional_bool(*switch_obj, "invert_logic", false);
validate_switch_config(config);
@@ -397,6 +362,9 @@ void validate_combo_key(const ipc::ComboKey& key, const RunConfig& config) {
if (endpoint.slot_size_bytes % 8U != 0U) {
throw std::runtime_error("Ring slot_size_bytes must be a multiple of 8 for " + key);
}
+ if (endpoint.name.empty() && key == "preprocessed_tap") {
+ return endpoint;
+ }
if (endpoint.name.empty() || endpoint.name.front() != '/') {
throw std::runtime_error("Ring name must start with '/' for " + key);
}
@@ -462,59 +430,18 @@ auto load_run_config(const std::string& path) -> RunConfig {
{
const auto* radar_obj = as_object(required_field(*root_obj, "radar"), "radar");
- config.radar.model = optional_string(*radar_obj, "model", "librevna");
- config.radar.serial = optional_string(*radar_obj, "serial", "");
- config.radar.driver_mode = parse_driver_mode(optional_string(*radar_obj, "driver_mode", "mock"));
-
- const auto* sweep_obj = as_object(required_field(*radar_obj, "sweep"), "radar.sweep");
- config.radar.sweep.start_hz =
- number_to_f32(as_number(required_field(*sweep_obj, "start_hz"), "radar.sweep.start_hz"), "radar.sweep.start_hz");
- config.radar.sweep.stop_hz =
- number_to_f32(as_number(required_field(*sweep_obj, "stop_hz"), "radar.sweep.stop_hz"), "radar.sweep.stop_hz");
- if (const auto* points_value = optional_field(*sweep_obj, "points"); points_value != nullptr) {
- config.radar.sweep.points =
- number_to_u32(as_number(*points_value, "radar.sweep.points"), "radar.sweep.points");
- } else if (config.radar.model != "kamil_adc") {
- throw std::runtime_error("Missing required config field: points");
- }
- config.radar.sweep.if_bandwidth_hz = number_to_f32(
- as_number(required_field(*sweep_obj, "if_bandwidth_hz"), "radar.sweep.if_bandwidth_hz"), "radar.sweep.if_bandwidth_hz"
- );
- config.radar.sweep.power_dbm = optional_f32(*sweep_obj, "stimulus_power_dbm", -10.0F);
- validate_radar_sweep(config.radar.sweep);
+ config.radar_model = optional_string(*radar_obj, "model", "librevna");
}
{
const auto* switches_obj = as_object(required_field(*root_obj, "switches"), "switches");
-
- if (const auto* port1_value = optional_field(*switches_obj, "port1"); port1_value != nullptr) {
- const auto* port1_obj = as_object(*port1_value, "switches.port1");
- const auto* port2_obj = as_object(required_field(*switches_obj, "port2"), "switches.port2");
- config.output_switch = parse_switch_config(*port1_obj, "port1", 1);
- config.input_switch = parse_switch_config(*port2_obj, "port2", 2);
- } else {
- config.input_switch = parse_switch_config(
- *as_object(required_field(*switches_obj, "input"), "switches.input"),
- "input",
- 2
- );
- config.output_switch = parse_switch_config(
- *as_object(required_field(*switches_obj, "output"), "switches.output"),
- "output",
- 1
- );
- }
-
- if (config.input_switch.radar_port == config.output_switch.radar_port) {
- throw std::runtime_error("Switch radar_port mapping must be unique for two switches");
- }
+ config.output_switch = parse_switch_config(required_field(*switches_obj, "port1"), "port1");
+ config.input_switch = parse_switch_config(required_field(*switches_obj, "port2"), "port2");
}
{
const auto* run_obj = as_object(required_field(*root_obj, "run"), "run");
- config.runtime.settling_ms = optional_u32(*run_obj, "settling_ms", 0);
config.runtime.idle_sleep_ms = optional_u32(*run_obj, "idle_sleep_ms", 2);
- config.runtime.continuous = optional_bool(*run_obj, "continuous", true);
config.runtime.processing_live_config_path = optional_string(
*run_obj,
"processing_live_config_path",
@@ -560,17 +487,6 @@ auto load_run_config(const std::string& path) -> RunConfig {
parse_preprocess_asset(*s21_obj, "object_calibration", "preprocess.s21");
}
- const auto* s11_obj = as_object(required_field(*preprocess_obj, "s11"), "preprocess.s11");
- const auto* s11_calibration_obj =
- as_object(required_field(*s11_obj, "calibration"), "preprocess.s11.calibration");
- config.preprocess.s11.calibration.open =
- parse_preprocess_asset(*s11_calibration_obj, "open", "preprocess.s11.calibration");
- config.preprocess.s11.calibration.short_standard =
- parse_preprocess_asset(*s11_calibration_obj, "short", "preprocess.s11.calibration");
- config.preprocess.s11.calibration.load =
- parse_preprocess_asset(*s11_calibration_obj, "load", "preprocess.s11.calibration");
- config.preprocess.s11.reference = parse_preprocess_asset(*s11_obj, "reference", "preprocess.s11");
-
if (const auto* notch_value = optional_field(*preprocess_obj, "notch"); notch_value != nullptr) {
config.preprocess.notch = parse_preprocess_notch(*as_object(*notch_value, "preprocess.notch"), "preprocess.notch");
}
@@ -585,7 +501,6 @@ auto load_run_config(const std::string& path) -> RunConfig {
{
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_tap = parse_ring_endpoint(*rings_obj, "raw_tap", config.rings.raw_tap);
config.rings.preprocessed = parse_ring_endpoint(*rings_obj, "preprocessed", config.rings.preprocessed);
config.rings.preprocessed_tap = parse_ring_endpoint(
*rings_obj,
diff --git a/data_acq_and_processing/sweep_orchestrator/device_drivers/switches/h7992_minimal_driver.hpp b/data_acq_and_processing/common_cpp/hardware/include/h7992_minimal_driver.hpp
similarity index 75%
rename from data_acq_and_processing/sweep_orchestrator/device_drivers/switches/h7992_minimal_driver.hpp
rename to data_acq_and_processing/common_cpp/hardware/include/h7992_minimal_driver.hpp
index 3133e20..bc555dd 100644
--- a/data_acq_and_processing/sweep_orchestrator/device_drivers/switches/h7992_minimal_driver.hpp
+++ b/data_acq_and_processing/common_cpp/hardware/include/h7992_minimal_driver.hpp
@@ -4,7 +4,6 @@
#include
#include "run_config.hpp"
-#include "switch_driver.hpp"
namespace radar::drivers {
@@ -27,15 +26,14 @@ struct H7992MinimalDriverSettings {
* Native mode uses Linux GPIO character-device API (uAPI v2).
* Mock mode keeps state in memory without touching hardware.
*/
-class H7992MinimalDriver final : public SwitchDriver {
+class H7992MinimalDriver {
public:
explicit H7992MinimalDriver(H7992MinimalDriverSettings settings);
- ~H7992MinimalDriver() override; // Leaves RF path safe even if close() was skipped.
+ ~H7992MinimalDriver(); // Leaves RF path safe even if close() was skipped.
- void open() override;
- void close() override;
- [[nodiscard]] auto position_count() const -> std::uint32_t override;
- void switch_to(std::uint32_t position) override;
+ void open();
+ void close();
+ void switch_to(std::uint32_t position);
private:
void open_native();
diff --git a/data_acq_and_processing/sweep_orchestrator/device_drivers/switches/h7992_minimal_driver.cpp b/data_acq_and_processing/common_cpp/hardware/src/h7992_minimal_driver.cpp
similarity index 98%
rename from data_acq_and_processing/sweep_orchestrator/device_drivers/switches/h7992_minimal_driver.cpp
rename to data_acq_and_processing/common_cpp/hardware/src/h7992_minimal_driver.cpp
index 3032d5b..0405492 100644
--- a/data_acq_and_processing/sweep_orchestrator/device_drivers/switches/h7992_minimal_driver.cpp
+++ b/data_acq_and_processing/common_cpp/hardware/src/h7992_minimal_driver.cpp
@@ -83,10 +83,6 @@ void H7992MinimalDriver::close() {
is_open_ = false;
}
-auto H7992MinimalDriver::position_count() const -> std::uint32_t {
- return settings_.positions;
-}
-
void H7992MinimalDriver::switch_to(std::uint32_t position) {
if (!is_open_) {
throw std::runtime_error("Switch driver is not open for " + settings_.name);
diff --git a/data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp b/data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp
index 65721e4..7270479 100644
--- a/data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp
+++ b/data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp
@@ -34,10 +34,8 @@ struct ComboKeyHash {
struct SweepTraceBlock {
ComboKey combo{};
- // Frequency axis in Hz. Must have the same size as `s11` and `s21`.
+ // Frequency axis in Hz. Must have the same size as `s21`.
std::vector frequency_hz{};
- // Complex S11 samples for matching frequency points.
- std::vector s11{};
// Complex S21 samples for matching frequency points.
std::vector s21{};
// Monotonic window in which THIS trace's sweep was measured, excluding the
@@ -92,11 +90,12 @@ struct ResultBlock {
struct ResultCollection {
std::uint64_t collection_id = 0;
std::uint64_t monotonic_ns = 0;
- // Wall-clock duration of `process_collection()` for this collection,
- // measured on the data_processor side with a monotonic clock. Used by the
- // Python pipeline to report processing-time metrics. Zero is a valid
- // "unmeasured" sentinel for legacy producers.
+ // Processing duration and acquisition identity, independent of stage completion time.
std::uint64_t processing_duration_ns = 0;
+ std::uint64_t settings_revision = 0;
+ std::uint64_t capture_start_ns = 0;
+ std::uint64_t capture_end_ns = 0;
+ float speed_m_s = 0.0F;
std::vector collection_payloads{};
std::vector blocks{};
};
@@ -111,7 +110,6 @@ struct ResultCollection {
-> PreprocessedCollection;
[[nodiscard]] auto serialize_result_collection(const ResultCollection& collection) -> std::vector;
-[[nodiscard]] auto deserialize_result_collection(std::span bytes) -> ResultCollection;
// Monotonic timestamp helper used for produced collections.
[[nodiscard]] auto current_monotonic_ns() -> std::uint64_t;
diff --git a/data_acq_and_processing/common_cpp/ipc/include/shm_ring.hpp b/data_acq_and_processing/common_cpp/ipc/include/shm_ring.hpp
index a86a20b..282c57c 100644
--- a/data_acq_and_processing/common_cpp/ipc/include/shm_ring.hpp
+++ b/data_acq_and_processing/common_cpp/ipc/include/shm_ring.hpp
@@ -25,15 +25,7 @@ class ShmRing {
std::uint32_t slot_size_bytes
) -> ShmRing;
- // Opens ring by name and validates wire-format header fields.
- [[nodiscard]] static auto open_existing(const std::string& name) -> ShmRing;
- // Removes shared memory object by name (safe if it does not exist).
- static void unlink_ring(const std::string& name);
-
- [[nodiscard]] auto is_open() const -> bool;
- [[nodiscard]] auto capacity() const -> std::uint32_t;
[[nodiscard]] auto slot_size_bytes() const -> std::uint32_t;
- [[nodiscard]] auto dropped_count() const -> std::uint64_t;
// Pushes payload with overwrite-oldest policy on overflow.
[[nodiscard]] auto push(std::span payload) -> bool;
@@ -57,8 +49,10 @@ class ShmRing {
void close();
static void validate_name(const std::string& name);
- // Validates that mapped_size covers the geometry declared in the header
- // (fix #50: reject undersized/corrupt mappings to prevent OOB slot access).
+ [[nodiscard]] static auto checked_mapped_size(
+ std::uint32_t capacity, std::uint32_t slot_size_bytes, const std::string& name
+ ) -> std::size_t;
+ // Reject undersized/corrupt mappings before accessing any slot.
static void validate_geometry(const Header& header, std::size_t mapped_size, const std::string& name);
};
diff --git a/data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp b/data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp
index 42590a9..b85ade5 100644
--- a/data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp
+++ b/data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp
@@ -1,5 +1,6 @@
#include "shared_types.hpp"
+#include
#include
#include
#include
@@ -12,9 +13,11 @@
namespace radar::ipc {
namespace {
-constexpr std::uint32_t kRawCollectionMagic = 0x32574152U; // RAW2
-constexpr std::uint32_t kPreprocessedCollectionMagic = 0x32525050U; // PRP2
-constexpr std::uint32_t kResultCollectionMagic = 0x324C5352U; // RSL2
+static_assert(std::endian::native == std::endian::little, "IPC requires a little-endian host");
+
+constexpr std::uint32_t kRawCollectionMagic = 0x33574152U; // RAW3
+constexpr std::uint32_t kPreprocessedCollectionMagic = 0x33505250U; // PRP3
+constexpr std::uint32_t kResultCollectionMagic = 0x334C5352U; // RSL3
template
concept TriviallySerializable = std::is_trivially_copyable_v;
@@ -75,16 +78,6 @@ class BinaryReader {
return value;
}
- [[nodiscard]] auto read_string() -> std::string {
- const auto size = read();
- ensure_available(size);
-
- const auto* begin = reinterpret_cast(bytes_.data() + offset_);
- std::string value(begin, begin + size);
- offset_ += size;
- return value;
- }
-
[[nodiscard]] auto is_consumed() const -> bool {
return offset_ == bytes_.size();
}
@@ -106,7 +99,6 @@ class BinaryReader {
void write_trace_block(BinaryWriter& writer, const SweepTraceBlock& trace) {
ensure_equal_sizes(trace.frequency_hz.size(), trace.s21.size(), "Sweep trace");
- ensure_equal_sizes(trace.frequency_hz.size(), trace.s11.size(), "Sweep trace S11");
writer.write(trace.combo.input_pos);
writer.write(trace.combo.output_pos);
@@ -116,11 +108,6 @@ void write_trace_block(BinaryWriter& writer, const SweepTraceBlock& trace) {
writer.write(frequency_hz);
}
- for (const auto& point : trace.s11) {
- writer.write(point.re);
- writer.write(point.im);
- }
-
for (const auto& point : trace.s21) {
writer.write(point.re);
writer.write(point.im);
@@ -144,23 +131,15 @@ void require_count_fits(std::uint64_t count, std::size_t min_bytes_each, BinaryR
trace.combo.output_pos = reader.read();
const auto point_count = reader.read();
- // Each point encodes frequency (4B) + s11 (8B) + s21 (8B) = 20 bytes.
- require_count_fits(point_count, 20U, reader);
+ // Each point encodes frequency (4B) + s21 (8B) = 12 bytes.
+ require_count_fits(point_count, 12U, reader);
trace.frequency_hz.reserve(point_count);
- trace.s11.reserve(point_count);
trace.s21.reserve(point_count);
for (std::uint32_t index = 0; index < point_count; ++index) {
trace.frequency_hz.push_back(reader.read());
}
- for (std::uint32_t index = 0; index < point_count; ++index) {
- trace.s11.push_back(Complex32{
- .re = reader.read(),
- .im = reader.read(),
- });
- }
-
for (std::uint32_t index = 0; index < point_count; ++index) {
trace.s21.push_back(Complex32{
.re = reader.read(),
@@ -171,10 +150,7 @@ void require_count_fits(std::uint64_t count, std::size_t min_bytes_each, BinaryR
return trace;
}
-// The capture windows live in a TRAILER after the trace blocks rather than inside
-// them, so a reader built before they existed still decodes every trace and simply
-// stops early. The trailer grows the same way: collection window first, then the
-// per-trace window table (one pair per trace, in trace order).
+// Capture trailer: collection window, then one per-trace window in trace order.
void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const RawSweepCollection& collection) {
writer.write(magic);
writer.write(collection.collection_id);
@@ -213,22 +189,9 @@ void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const Raw
collection.traces.push_back(read_trace_block(reader));
}
- // Each trailer stage is optional: a payload from an older producer stops after
- // the trace blocks (or after the collection window) and leaves the rest zeroed.
- if (reader.remaining_bytes() == 0U) {
- return collection;
- }
- if (reader.remaining_bytes() < (sizeof(std::uint64_t) * 2U)) {
- throw std::runtime_error("Truncated capture window in trace collection");
- }
-
collection.capture_start_ns = reader.read();
collection.capture_end_ns = reader.read();
- if (reader.remaining_bytes() == 0U) {
- return collection;
- }
-
const auto trace_time_count = reader.read();
if (trace_time_count != collection.traces.size()) {
throw std::runtime_error("Per-trace capture window count does not match trace count");
@@ -254,24 +217,6 @@ void write_trace_result_payload(BinaryWriter& writer, const ResultPayload& paylo
}
}
-auto read_trace_result_payload(BinaryReader& reader, ResultPayload* payload) -> void {
- const auto point_count = reader.read();
- require_count_fits(point_count, 12U, reader);
-
- payload->frequency_hz.reserve(point_count);
- payload->trace.reserve(point_count);
-
- for (std::uint32_t index = 0; index < point_count; ++index) {
- payload->frequency_hz.push_back(reader.read());
- }
- for (std::uint32_t index = 0; index < point_count; ++index) {
- payload->trace.push_back(Complex32{
- .re = reader.read(),
- .im = reader.read(),
- });
- }
-}
-
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");
@@ -293,27 +238,6 @@ void write_image_result_payload(BinaryWriter& writer, const ResultPayload& paylo
}
}
-auto read_image_result_payload(BinaryReader& reader, ResultPayload* payload) -> void {
- const auto x_count = reader.read();
- const auto y_count = reader.read();
- const auto value_count = static_cast(x_count) * y_count;
- require_count_fits(value_count + x_count + y_count, sizeof(float), reader);
-
- payload->image_x_axis.reserve(x_count);
- payload->image_y_axis.reserve(y_count);
- payload->image_values.reserve(static_cast(value_count));
-
- for (std::uint32_t index = 0; index < x_count; ++index) {
- payload->image_x_axis.push_back(reader.read());
- }
- for (std::uint32_t index = 0; index < y_count; ++index) {
- payload->image_y_axis.push_back(reader.read());
- }
- for (std::size_t index = 0; index < value_count; ++index) {
- payload->image_values.push_back(reader.read());
- }
-}
-
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()) {
@@ -333,21 +257,6 @@ void write_table_result_payload(BinaryWriter& writer, const ResultPayload& paylo
}
}
-auto read_table_result_payload(BinaryReader& reader, ResultPayload* payload) -> void {
- const auto column_count = reader.read();
- const auto row_count = reader.read();
- if (column_count == 0U && row_count != 0U) {
- throw std::runtime_error("Result table cannot have rows without columns");
- }
- const auto value_count = static_cast(column_count) * row_count;
- require_count_fits(value_count, sizeof(float), reader);
- payload->table_columns = column_count;
- payload->table_values.reserve(static_cast(value_count));
- for (std::size_t index = 0; index < value_count; ++index) {
- payload->table_values.push_back(reader.read());
- }
-}
-
void write_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
writer.write(static_cast(payload.kind));
writer.write_string(payload.processing_name);
@@ -370,29 +279,6 @@ void write_result_payload(BinaryWriter& writer, const ResultPayload& payload) {
}
}
-[[nodiscard]] auto read_result_payload(BinaryReader& reader) -> ResultPayload {
- ResultPayload payload{};
- payload.kind = static_cast(reader.read());
- payload.processing_name = reader.read_string();
-
- switch (payload.kind) {
- case ResultKind::TraceComplex:
- read_trace_result_payload(reader, &payload);
- return payload;
- case ResultKind::ScalarF32:
- payload.scalar_value = reader.read();
- 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:
- throw std::runtime_error("Unsupported result payload kind in stream");
- }
-}
-
void write_result_block(BinaryWriter& writer, const ResultBlock& block) {
writer.write(block.combo.input_pos);
writer.write(block.combo.output_pos);
@@ -403,21 +289,6 @@ void write_result_block(BinaryWriter& writer, const ResultBlock& block) {
}
}
-[[nodiscard]] auto read_result_block(BinaryReader& reader) -> ResultBlock {
- ResultBlock block{};
- block.combo.input_pos = reader.read();
- block.combo.output_pos = reader.read();
-
- const auto payload_count = reader.read();
- require_count_fits(payload_count, 3U, reader);
- block.payloads.reserve(payload_count);
- for (std::uint32_t index = 0; index < payload_count; ++index) {
- block.payloads.push_back(read_result_payload(reader));
- }
-
- return block;
-}
-
void ensure_reader_consumed(const BinaryReader& reader, const std::string& payload_label) {
if (!reader.is_consumed()) {
throw std::runtime_error("Unexpected trailing bytes in " + payload_label);
@@ -458,6 +329,10 @@ auto serialize_result_collection(const ResultCollection& collection) -> std::vec
writer.write(collection.collection_id);
writer.write(collection.monotonic_ns);
writer.write(collection.processing_duration_ns);
+ writer.write(collection.settings_revision);
+ writer.write(collection.capture_start_ns);
+ writer.write(collection.capture_end_ns);
+ writer.write(collection.speed_m_s);
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"));
@@ -471,37 +346,6 @@ auto serialize_result_collection(const ResultCollection& collection) -> std::vec
return std::move(writer).finish();
}
-auto deserialize_result_collection(std::span bytes) -> ResultCollection {
- BinaryReader reader(bytes);
-
- const auto magic = reader.read();
- if (magic != kResultCollectionMagic) {
- throw std::runtime_error("Unexpected result collection magic");
- }
-
- ResultCollection collection{};
- collection.collection_id = reader.read();
- collection.monotonic_ns = reader.read();
- collection.processing_duration_ns = reader.read();
-
- const auto collection_payload_count = reader.read();
- const auto block_count = reader.read();
- require_count_fits(collection_payload_count, 3U, reader);
- 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));
- }
-
- require_count_fits(block_count, 12U, reader);
- collection.blocks.reserve(block_count);
- for (std::uint32_t index = 0; index < block_count; ++index) {
- collection.blocks.push_back(read_result_block(reader));
- }
-
- ensure_reader_consumed(reader, "result collection");
- return collection;
-}
-
auto current_monotonic_ns() -> std::uint64_t {
const auto now = std::chrono::steady_clock::now().time_since_epoch();
const auto now_ns = std::chrono::duration_cast(now).count();
diff --git a/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp b/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp
index a37659e..cae935c 100644
--- a/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp
+++ b/data_acq_and_processing/common_cpp/ipc/src/shm_ring.cpp
@@ -44,63 +44,10 @@ constexpr auto kHeaderInitWaitTimeout = std::chrono::milliseconds(1000);
constexpr auto kHeaderInitPollInterval = std::chrono::milliseconds(2);
constexpr std::uint64_t kMaxMappedSize = 1ULL << 40U;
-[[nodiscard]] auto checked_u64_diff(std::uint64_t left, std::uint64_t right) -> std::uint64_t {
- return left >= right ? left - right : 0;
-}
-
[[nodiscard]] auto errno_message(const std::string& action, const std::string& name) -> std::runtime_error {
return std::runtime_error(action + " " + name + ": " + std::strerror(errno));
}
-class ScopedFd {
- public:
- explicit ScopedFd(int fd) : fd_(fd) {}
-
- ~ScopedFd() {
- if (fd_ >= 0) {
- ::close(fd_);
- }
- }
-
- ScopedFd(const ScopedFd&) = delete;
- auto operator=(const ScopedFd&) -> ScopedFd& = delete;
-
- ScopedFd(ScopedFd&& other) noexcept : fd_(std::exchange(other.fd_, -1)) {}
-
- auto release() -> int {
- return std::exchange(fd_, -1);
- }
-
- private:
- int fd_ = -1;
-};
-
-class ScopedMmap {
- public:
- ScopedMmap(void* address, std::size_t size) : address_(address), size_(size) {}
-
- ~ScopedMmap() {
- if (address_ != nullptr && address_ != MAP_FAILED) {
- munmap(address_, size_);
- }
- }
-
- ScopedMmap(const ScopedMmap&) = delete;
- auto operator=(const ScopedMmap&) -> ScopedMmap& = delete;
-
- ScopedMmap(ScopedMmap&& other) noexcept
- : address_(std::exchange(other.address_, nullptr)),
- size_(std::exchange(other.size_, 0U)) {}
-
- auto release() -> std::pair {
- return {std::exchange(address_, nullptr), std::exchange(size_, 0U)};
- }
-
- private:
- void* address_ = nullptr;
- std::size_t size_ = 0;
-};
-
} // namespace
ShmRing::~ShmRing() {
@@ -129,28 +76,7 @@ auto ShmRing::open_or_create(
std::uint32_t slot_size_bytes
) -> ShmRing {
validate_name(name);
- if (capacity == 0U) {
- throw std::runtime_error("Ring capacity must be > 0");
- }
- if (slot_size_bytes == 0U) {
- throw std::runtime_error("Ring slot size must be > 0");
- }
- if (slot_size_bytes % alignof(SlotHeader) != 0U) {
- throw std::runtime_error("Ring slot size must be a multiple of 8");
- }
-
- const auto stride = sizeof(SlotHeader) + static_cast(slot_size_bytes);
- if (stride > (kMaxMappedSize - sizeof(Header)) / capacity) {
- throw std::runtime_error("Shared memory ring exceeds 1 TiB for " + name);
- }
- const auto mapped_size_u64 = sizeof(Header) + stride * capacity;
- if (mapped_size_u64 > kMaxMappedSize
- || mapped_size_u64 > std::numeric_limits::max()
- || mapped_size_u64 > static_cast(std::numeric_limits::max())) {
- throw std::runtime_error("Shared memory ring geometry overflows for " + name);
- }
- const auto mapped_size = static_cast(mapped_size_u64);
-
+ const auto mapped_size = checked_mapped_size(capacity, slot_size_bytes, name);
bool created = false;
int fd = shm_open(name.c_str(), O_RDWR | O_CREAT | O_EXCL, 0660);
@@ -164,8 +90,8 @@ auto ShmRing::open_or_create(
throw errno_message("Failed to open shared memory ring", name);
}
- ScopedFd scoped_fd(fd);
-
+ ShmRing ring{};
+ ring.fd_ = fd;
if (created) {
if (ftruncate(fd, static_cast(mapped_size)) != 0) {
@@ -195,30 +121,29 @@ auto ShmRing::open_or_create(
throw errno_message("Failed to mmap shared memory ring", name);
}
- ScopedMmap scoped_mmap(mapped, mapped_size);
- auto* header = static_cast(mapped);
+ ring.mapped_ = mapped;
+ ring.mapped_size_ = mapped_size;
+ auto* header = ring.header_ = static_cast(mapped);
if (created) {
// ftruncate already supplies zero-filled slots; leave them demand-paged
// instead of touching the full ring before any payload is published.
std::memset(mapped, 0, sizeof(Header));
std::memcpy(header->magic, kRingMagic.data(), kRingMagic.size());
- header->version = kRingVersion;
header->capacity = capacity;
header->slot_size_bytes = slot_size_bytes;
new (&header->write_seq) std::atomic(0);
new (&header->read_seq) std::atomic(0);
new (&header->dropped) std::atomic(0);
- std::atomic_thread_fence(std::memory_order_release);
+ // Publish readiness only after geometry and cursors have been initialized.
+ std::atomic_ref(header->version).store(kRingVersion, std::memory_order_release);
} else {
const auto deadline = std::chrono::steady_clock::now() + kHeaderInitWaitTimeout;
while (true) {
- std::atomic_thread_fence(std::memory_order_acquire);
-
+ const bool version_ok = std::atomic_ref(header->version).load(std::memory_order_acquire) == kRingVersion;
const bool magic_ok = std::memcmp(header->magic, kRingMagic.data(), kRingMagic.size()) == 0;
- const bool version_ok = header->version == kRingVersion;
const bool geometry_ok = header->capacity == capacity && header->slot_size_bytes == slot_size_bytes;
if (magic_ok && version_ok && geometry_ok) {
@@ -247,89 +172,13 @@ auto ShmRing::open_or_create(
}
}
- ShmRing ring{};
- ring.fd_ = scoped_fd.release();
-
- const auto [released_mapped, released_size] = scoped_mmap.release();
- ring.mapped_ = released_mapped;
- ring.mapped_size_ = released_size;
- ring.header_ = static_cast(released_mapped);
return ring;
}
-auto ShmRing::open_existing(const std::string& name) -> ShmRing {
- validate_name(name);
-
- const int fd = shm_open(name.c_str(), O_RDWR, 0660);
- if (fd < 0) {
- throw errno_message("Failed to open shared memory ring", name);
- }
-
- ScopedFd scoped_fd(fd);
-
- struct stat info {};
- if (fstat(fd, &info) != 0) {
- throw errno_message("Failed to stat shared memory ring", name);
- }
-
- if (info.st_size < static_cast(sizeof(Header))) {
- throw std::runtime_error("Shared memory ring size is too small for " + name);
- }
-
- const auto mapped_size = static_cast(info.st_size);
- void* mapped = mmap(nullptr, mapped_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
- if (mapped == MAP_FAILED) {
- throw errno_message("Failed to mmap shared memory ring", name);
- }
-
- ScopedMmap scoped_mmap(mapped, mapped_size);
- auto* header = static_cast(mapped);
-
- if (std::memcmp(header->magic, kRingMagic.data(), kRingMagic.size()) != 0) {
- throw std::runtime_error("Shared memory ring magic mismatch for " + name);
- }
- if (header->version != kRingVersion) {
- throw std::runtime_error("Shared memory ring version mismatch for " + name);
- }
- // ensure the mapping actually spans every slot the header describes.
- validate_geometry(*header, mapped_size, name);
-
- ShmRing ring{};
- ring.fd_ = scoped_fd.release();
-
- const auto [released_mapped, released_size] = scoped_mmap.release();
- ring.mapped_ = released_mapped;
- ring.mapped_size_ = released_size;
- ring.header_ = static_cast(released_mapped);
- return ring;
-}
-
-void ShmRing::unlink_ring(const std::string& name) {
- validate_name(name);
- if (shm_unlink(name.c_str()) != 0 && errno != ENOENT) {
- throw errno_message("Failed to unlink shared memory ring", name);
- }
-}
-
-auto ShmRing::is_open() const -> bool {
- return header_ != nullptr;
-}
-
-auto ShmRing::capacity() const -> std::uint32_t {
- return header_ != nullptr ? header_->capacity : 0;
-}
-
auto ShmRing::slot_size_bytes() const -> std::uint32_t {
return header_ != nullptr ? header_->slot_size_bytes : 0;
}
-auto ShmRing::dropped_count() const -> std::uint64_t {
- if (header_ == nullptr) {
- return 0;
- }
- return header_->dropped.load(std::memory_order_acquire);
-}
-
auto ShmRing::push(std::span payload) -> bool {
if (header_ == nullptr) {
throw std::runtime_error("Ring is not open");
@@ -341,7 +190,7 @@ auto ShmRing::push(std::span payload) -> bool {
const auto write_seq = header_->write_seq.load(std::memory_order_relaxed);
auto read_seq = header_->read_seq.load(std::memory_order_acquire);
- if (checked_u64_diff(write_seq, read_seq) >= header_->capacity) {
+ if (write_seq >= read_seq && write_seq - read_seq >= header_->capacity) {
// Ring full: overwrite the oldest unread slot. Advance read_seq via CAS so a
// concurrent consumer's advance is never clobbered (read_seq never moves back).
header_->read_seq.compare_exchange_strong(
@@ -444,37 +293,41 @@ void ShmRing::close() {
}
void ShmRing::validate_name(const std::string& name) {
- if (name.empty() || name.front() != '/') {
- throw std::runtime_error("POSIX shm name must start with '/'");
+ if (name.size() < 2U || name.front() != '/' || name.find('/', 1) != std::string::npos
+ || name.find('\0') != std::string::npos) {
+ throw std::runtime_error("POSIX shm name must be '/' followed by a single nonempty filename");
}
}
-void ShmRing::validate_geometry(const Header& header, std::size_t mapped_size, const std::string& name) {
- // derive the expected size from the header's own geometry fields and
- // require the real mapping to cover it. A bogus capacity/slot_size or a truncated
- // mapping would otherwise yield out-of-bounds slot offsets and a SIGSEGV.
- const std::uint32_t capacity = header.capacity;
- const std::uint32_t slot_size_bytes = header.slot_size_bytes;
+auto ShmRing::checked_mapped_size(
+ std::uint32_t capacity, std::uint32_t slot_size_bytes, const std::string& name
+) -> std::size_t {
+ static_assert(sizeof(Header) == 64 && sizeof(SlotHeader) == 16);
if (capacity == 0U) {
- throw std::runtime_error("Shared memory ring capacity is zero for " + name);
+ throw std::runtime_error("Ring capacity must be > 0");
}
if (slot_size_bytes == 0U) {
- throw std::runtime_error("Shared memory ring slot size is zero for " + name);
+ throw std::runtime_error("Ring slot size must be > 0");
}
if (slot_size_bytes % alignof(SlotHeader) != 0U) {
- throw std::runtime_error("Shared memory ring slot size must be a multiple of 8 for " + name);
+ throw std::runtime_error("Ring slot size must be a multiple of 8");
}
const auto stride = sizeof(SlotHeader) + static_cast(slot_size_bytes);
if (stride > (kMaxMappedSize - sizeof(Header)) / capacity) {
throw std::runtime_error("Shared memory ring exceeds 1 TiB for " + name);
}
- const auto expected_size = sizeof(Header) + stride * capacity;
- if (expected_size > kMaxMappedSize || expected_size > std::numeric_limits::max()) {
+ const auto mapped_size_u64 = sizeof(Header) + stride * capacity;
+ if (mapped_size_u64 > kMaxMappedSize
+ || mapped_size_u64 > std::numeric_limits::max()
+ || mapped_size_u64 > static_cast(std::numeric_limits::max())) {
throw std::runtime_error("Shared memory ring geometry overflows for " + name);
}
+ return static_cast(mapped_size_u64);
+}
- if (mapped_size < expected_size) {
+void ShmRing::validate_geometry(const Header& header, std::size_t mapped_size, const std::string& name) {
+ if (mapped_size < checked_mapped_size(header.capacity, header.slot_size_bytes, name)) {
throw std::runtime_error("Shared memory ring size is too small for " + name);
}
}
diff --git a/data_acq_and_processing/kamil_adc_collector/README.md b/data_acq_and_processing/kamil_adc_collector/README.md
index eea20b2..6528a58 100644
--- a/data_acq_and_processing/kamil_adc_collector/README.md
+++ b/data_acq_and_processing/kamil_adc_collector/README.md
@@ -2,11 +2,11 @@
Standalone acquisition binary for the **L-Card E-502** ADC. It captures the
dual-channel main + reference IQ stream and publishes it as 8-byte framed packets
-over a PTY, which the Python `kamil_adc` producer
-(`python_app/hardware_full/kamil_adc`) reads and processes.
+over a PTY. `KamilAdcService` in `python_app/hardware_full/kamil_adc` reads and
+processes that stream for `python_app.scripts.kamil_adc_raw_producer`.
-This is the data *source* for `radar.model == "kamil_adc"`; it is the Kamil ADC
-counterpart to the C++ `sweep_orchestrator` used by LibreVNA devices.
+This is the data *source* for `radar.model == "kamil_adc"`, paired with the Python
+`kamil_adc_raw_producer`. LibreVNA acquisition uses Python drivers.
## Layout
@@ -16,15 +16,13 @@ counterpart to the C++ `sweep_orchestrator` used by LibreVNA devices.
| `include/` | Headers for the writers plus `switch_sequencer.h`. |
| `vendor/lcard/` | L-Card X502/E502 SDK headers (`x502api.h`, `e502api.h`, `lcard_pstdint.h`), vendored verbatim so the collector builds with no external include path. |
-The sources started as a verbatim import of the original standalone tool, but are
-**no longer identical to it**: the collector now drives the RF switches itself
-(switch-aware mode, `SwitchSequencer`), stepping to the next combination inside
-the hardware idle gap between sweeps and flagging a sweep *dirty* when a readout
-backlog means the toggle could not have landed in that gap. It therefore reuses
-the orchestrator's switch drivers and the shared `run_config` loader, which is why
-its build has its own include set and its own `build/kamil/` object tree.
-
-The wire format towards the Python producer is unchanged.
+In switch-aware mode, `SwitchSequencer` drives the RF switches, stepping to the
+next combination inside the hardware idle gap between sweeps and flagging a
+sweep *dirty* when a readout backlog means the toggle could not have landed in
+that gap. The Python reader
+drops dirty sweeps; the collector repeats that combination without another
+switch transition. The collector uses the common H7992 driver and `run_config`
+loader, with its own include set and `build/kamil/` object tree.
## Build
@@ -33,16 +31,25 @@ make kamil_adc_collector # -> build/bin/kamil_adc_collector
```
It is **not** part of `make all`; `start.sh` builds it automatically when the
-active config selects `radar.model == "kamil_adc"`. The build is self-contained:
-it needs only the vendored headers and links `-ldl -lutil -lpthread`.
+active config selects `radar.model == "kamil_adc"`, unless `--skip-build` is set.
+The Linux build uses the repository's common sources and vendored headers and
+links with `-pthread -ldl -lutil`; no installed L-Card SDK is needed to build it.
## Runtime
The collector loads the proprietary `libx502api.so` / `libe502api.so` at run time
-via `dlopen`, so those libraries must be on `LD_LIBRARY_PATH`. The Kamil ADC
-service prepends `~/.local/lib` (their default install location) unless the config
-pins `LD_LIBRARY_PATH` in `radar.kamil_adc.env`.
+via `dlopen`, so those libraries must be discoverable by the dynamic loader.
+The Kamil ADC service prepends `~/.local/lib` (their default install location)
+unless the config pins `LD_LIBRARY_PATH` in `radar.kamil_adc.env`.
The command-line arguments (capture profile, clocking, the `do8_freq_ref`
reference overlay, etc.) are supplied by `radar.kamil_adc.args` in the run config;
-the service appends the generated `tty:` argument.
+the service appends `tty:` from `radar.kamil_adc.tty_path`.
+
+When both RF switches use `driver_mode: "native"`, the producer also passes
+`config:` to the collector. After each sweep boundary, the collector emits
+an 8-byte combo tag `[0x00C0, input_pos, output_pos, dirty]` (four little-endian
+16-bit words). The producer accepts only clean sweeps for the requested combo.
+Otherwise, the producer selects and settles the switches itself; the collector
+runs without `config:` and emits no combo tags. Calibration captures also
+use this Python-controlled path.
diff --git a/data_acq_and_processing/kamil_adc_collector/include/switch_sequencer.h b/data_acq_and_processing/kamil_adc_collector/include/switch_sequencer.h
index f2ec2b9..e0df25b 100644
--- a/data_acq_and_processing/kamil_adc_collector/include/switch_sequencer.h
+++ b/data_acq_and_processing/kamil_adc_collector/include/switch_sequencer.h
@@ -6,7 +6,7 @@
#include
#include "shared_types.hpp"
-#include "switch_driver.hpp"
+#include "h7992_minimal_driver.hpp"
namespace radar::kamil {
@@ -47,8 +47,8 @@ class SwitchSequencer {
SwitchSequencer(
Settings settings,
- std::unique_ptr input_switch,
- std::unique_ptr output_switch
+ std::unique_ptr input_switch,
+ std::unique_ptr output_switch
)
: settings_(std::move(settings)),
input_switch_(std::move(input_switch)),
@@ -130,15 +130,14 @@ class SwitchSequencer {
private:
void apply(const radar::ipc::ComboKey& combo) {
- // Output first, then input — same order the sweep orchestrator uses, so
- // both acquisition paths drive an identical hardware sequence.
+ // Set the transmitter before the receiver.
output_switch_->switch_to(combo.output_pos);
input_switch_->switch_to(combo.input_pos);
}
Settings settings_;
- std::unique_ptr input_switch_;
- std::unique_ptr output_switch_;
+ std::unique_ptr input_switch_;
+ std::unique_ptr output_switch_;
std::size_t index_ = 0;
radar::ipc::ComboKey applied_{};
bool upcoming_dirty_ = false;
diff --git a/data_acq_and_processing/kamil_adc_collector/src/main.cpp b/data_acq_and_processing/kamil_adc_collector/src/main.cpp
index 258a278..0424b89 100644
--- a/data_acq_and_processing/kamil_adc_collector/src/main.cpp
+++ b/data_acq_and_processing/kamil_adc_collector/src/main.cpp
@@ -20,7 +20,6 @@
#include "switch_sequencer.h"
#include "run_config.hpp"
#include "h7992_minimal_driver.hpp"
-#include "hmc349a_minimal_driver.hpp"
#include
#include
@@ -1830,21 +1829,8 @@ void print_device_info(const t_x502_info& info) {
}
// Build one switch driver (native GPIO or mock) from a parsed switch config,
-// reusing the exact driver implementations the sweep orchestrator uses so both
-// acquisition paths drive identical hardware.
-std::unique_ptr make_switch_driver(const radar::config::SwitchConfig& sc) {
- if (sc.driver_kind == radar::config::SwitchDriverKind::HMC349A) {
- return std::make_unique(
- radar::drivers::HMC349AMinimalDriverSettings{
- .name = sc.name,
- .mode = sc.driver_mode,
- .positions = sc.positions,
- .default_position = sc.default_position,
- .gpio_chip = sc.gpio_chip,
- .pin_a = sc.pin_a,
- .invert_logic = sc.invert_logic,
- });
- }
+// using the shared H7992 GPIO implementation.
+std::unique_ptr make_switch_driver(const radar::config::SwitchConfig& sc) {
return std::make_unique(
radar::drivers::H7992MinimalDriverSettings{
.name = sc.name,
diff --git a/data_acq_and_processing/preprocessing/bundles/include/channel_bundle_support.hpp b/data_acq_and_processing/preprocessing/bundles/include/channel_bundle_support.hpp
new file mode 100644
index 0000000..5201e8d
--- /dev/null
+++ b/data_acq_and_processing/preprocessing/bundles/include/channel_bundle_support.hpp
@@ -0,0 +1,55 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include "shared_types.hpp"
+
+namespace radar::preprocessing {
+
+struct ChannelTrace {
+ std::vector frequency_hz{};
+ std::vector samples{};
+};
+
+class S21CalibrationBundle {
+ public:
+ void load(const std::string& path);
+
+ void validate_combos(const std::vector& combos) const;
+ void apply(
+ const ipc::ComboKey& combo,
+ std::span frequency_hz,
+ std::span measured
+ ) const;
+
+ private:
+ std::unordered_map traces_by_combo_{};
+};
+
+class S21ReferenceBundle {
+ public:
+ // Load and through-calibrate the active references once at startup.
+ void load(const std::string& path, const S21CalibrationBundle& calibration,
+ const std::vector& combos);
+ void apply(ipc::SweepTraceBlock& trace) const;
+
+ private:
+ std::unordered_map traces_by_combo_{};
+};
+
+// Object-derived MIMO correction, exported on each pair's runtime frequency grid.
+// An empty path disables it; enabled bundles must cover every active pair.
+class S21ObjectCalibrationBundle {
+ public:
+ void load(const std::string& path);
+ void validate_combos(const std::vector& combos) const;
+ void apply(ipc::SweepTraceBlock& trace) const;
+
+ private:
+ std::unordered_map factors_by_combo_{};
+};
+
+} // namespace radar::preprocessing
diff --git a/data_acq_and_processing/preprocessing/bundles/src/channel_bundle_support.cpp b/data_acq_and_processing/preprocessing/bundles/src/channel_bundle_support.cpp
new file mode 100644
index 0000000..572ea3e
--- /dev/null
+++ b/data_acq_and_processing/preprocessing/bundles/src/channel_bundle_support.cpp
@@ -0,0 +1,255 @@
+#include "channel_bundle_support.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace radar::preprocessing {
+namespace {
+
+constexpr float kFrequencyRelativeTolerance = 1e-5F;
+constexpr float kFrequencyAbsoluteTolerance = 1e-3F;
+[[nodiscard]] auto combo_to_string(const ipc::ComboKey& combo) -> std::string {
+ return "input=" + std::to_string(combo.input_pos) + " output=" + std::to_string(combo.output_pos);
+}
+
+[[nodiscard]] auto read_binary_file(const std::string& path, const std::string& bundle_label)
+ -> std::vector {
+ std::ifstream stream(path, std::ios::binary);
+ if (!stream.is_open()) {
+ throw std::runtime_error("Failed to open " + bundle_label + " bundle: " + path);
+ }
+
+ return std::vector(std::istreambuf_iterator(stream), std::istreambuf_iterator());
+}
+
+void validate_channel_trace_layout(
+ const ChannelTrace& trace,
+ const std::string& trace_label,
+ const ipc::ComboKey& combo
+) {
+ if (trace.frequency_hz.size() != trace.samples.size()) {
+ throw std::runtime_error(
+ trace_label + " frequency/channel vector size mismatch for combo " + combo_to_string(combo)
+ );
+ }
+ for (std::size_t i = 0; i < trace.samples.size(); ++i) {
+ if (!std::isfinite(trace.frequency_hz[i]) || !std::isfinite(trace.samples[i].re)
+ || !std::isfinite(trace.samples[i].im)) {
+ throw std::runtime_error(trace_label + " contains non-finite values for combo " + combo_to_string(combo));
+ }
+ }
+}
+
+[[nodiscard]] auto frequency_axes_match(std::span left, std::span right) -> bool {
+ if (left.size() != right.size()) {
+ return false;
+ }
+
+ for (std::size_t index = 0; index < left.size(); ++index) {
+ if (!std::isfinite(left[index]) || !std::isfinite(right[index])) {
+ return false;
+ }
+ const auto delta = std::fabs(left[index] - right[index]);
+ const auto scale = std::max(std::fabs(left[index]), std::fabs(right[index]));
+ const auto tolerance = std::max(kFrequencyAbsoluteTolerance, scale * kFrequencyRelativeTolerance);
+ if (delta > tolerance) {
+ return false;
+ }
+ }
+ return true;
+}
+
+void ensure_frequency_axis_match(
+ std::span expected,
+ std::span actual,
+ const std::string& label
+) {
+ if (!frequency_axes_match(expected, actual)) {
+ throw std::runtime_error(label + " frequency axis mismatch");
+ }
+}
+
+[[nodiscard]] auto to_channel_trace(
+ const ipc::SweepTraceBlock& trace,
+ const std::string& bundle_label
+) -> ChannelTrace {
+ if (trace.frequency_hz.empty()) {
+ throw std::runtime_error(bundle_label + " trace contains no frequency samples");
+ }
+
+ ChannelTrace channel_trace{};
+ channel_trace.frequency_hz = trace.frequency_hz;
+ channel_trace.samples = trace.s21;
+ validate_channel_trace_layout(channel_trace, bundle_label + " trace", trace.combo);
+ return channel_trace;
+}
+
+[[nodiscard]] auto load_channel_traces(
+ const std::string& path,
+ const std::string& bundle_label
+) -> std::unordered_map {
+ const auto bytes = read_binary_file(path, bundle_label);
+ if (bytes.empty()) {
+ throw std::runtime_error(bundle_label + " bundle is empty: " + path);
+ }
+
+ const auto collection = ipc::deserialize_raw_collection(bytes);
+ std::unordered_map traces_by_combo{};
+ traces_by_combo.reserve(collection.traces.size());
+ for (const auto& trace : collection.traces) {
+ // #47: reject duplicate combos instead of silently overwriting an earlier trace.
+ const auto inserted =
+ traces_by_combo.insert({trace.combo, to_channel_trace(trace, bundle_label)});
+ if (!inserted.second) {
+ throw std::runtime_error(
+ bundle_label + " bundle contains duplicate combo " + combo_to_string(trace.combo) + ": " + path
+ );
+ }
+ }
+
+ if (traces_by_combo.empty()) {
+ throw std::runtime_error(bundle_label + " bundle does not contain traces: " + path);
+ }
+ return traces_by_combo;
+}
+
+void validate_bundle_combos(
+ const std::unordered_map& traces_by_combo,
+ const std::vector& combos,
+ const std::string& bundle_label
+) {
+ if (traces_by_combo.empty()) {
+ return;
+ }
+
+ for (const auto& combo : combos) {
+ if (!traces_by_combo.contains(combo)) {
+ throw std::runtime_error(bundle_label + " is missing combo " + combo_to_string(combo));
+ }
+ }
+}
+
+template
+auto resolve_required(
+ const std::unordered_map& values_by_combo,
+ const ipc::ComboKey& combo,
+ const char* value_label
+) -> const TValue& {
+ const auto found = values_by_combo.find(combo);
+ if (found == values_by_combo.end()) {
+ throw std::runtime_error(std::string(value_label) + " is missing for combo " + combo_to_string(combo));
+ }
+ return found->second;
+}
+
+} // namespace
+
+void S21CalibrationBundle::load(const std::string& path) {
+ if (path.empty()) {
+ throw std::runtime_error("S21 calibration bundle path must not be empty");
+ }
+
+ traces_by_combo_ = load_channel_traces(path, "S21 calibration");
+}
+
+void S21CalibrationBundle::validate_combos(const std::vector& combos) const {
+ validate_bundle_combos(traces_by_combo_, combos, "S21 calibration bundle");
+}
+
+void S21CalibrationBundle::apply(
+ const ipc::ComboKey& combo,
+ std::span frequency_hz,
+ std::span measured
+) const {
+ const auto& calibration_trace = resolve_required(traces_by_combo_, combo, "S21 calibration trace");
+ ensure_frequency_axis_match(calibration_trace.frequency_hz, frequency_hz, "S21 calibration");
+ if (measured.size() != calibration_trace.samples.size()) {
+ throw std::runtime_error("S21 calibration vector size mismatch");
+ }
+ constexpr float epsilon = 1e-12F;
+ for (std::size_t index = 0; index < measured.size(); ++index) {
+ const auto standard = calibration_trace.samples[index];
+ const float magnitude_squared = standard.re * standard.re + standard.im * standard.im;
+ if (magnitude_squared > epsilon) {
+ const auto value = measured[index];
+ measured[index] = {
+ (value.re * standard.re + value.im * standard.im) / magnitude_squared,
+ (value.im * standard.re - value.re * standard.im) / magnitude_squared,
+ };
+ }
+ }
+}
+
+void S21ReferenceBundle::load(
+ const std::string& path,
+ const S21CalibrationBundle& calibration,
+ const std::vector& combos
+) {
+ const auto raw = load_channel_traces(path, "S21 reference");
+ decltype(traces_by_combo_) calibrated;
+ for (const auto& combo : combos) {
+ auto trace = resolve_required(raw, combo, "S21 reference");
+ calibration.apply(combo, trace.frequency_hz, trace.samples);
+ validate_channel_trace_layout(trace, "Calibrated S21 reference", combo);
+ calibrated.emplace(combo, std::move(trace));
+ }
+ traces_by_combo_ = std::move(calibrated);
+}
+
+void S21ReferenceBundle::apply(ipc::SweepTraceBlock& trace) const {
+ const auto& reference = resolve_required(traces_by_combo_, trace.combo, "Calibrated S21 reference");
+ ensure_frequency_axis_match(reference.frequency_hz, trace.frequency_hz, "S21 reference");
+ if (trace.s21.size() != reference.samples.size()) {
+ throw std::runtime_error("S21 reference point count mismatch");
+ }
+ for (std::size_t index = 0; index < trace.s21.size(); ++index) {
+ trace.s21[index].re -= reference.samples[index].re;
+ trace.s21[index].im -= reference.samples[index].im;
+ }
+}
+
+void S21ObjectCalibrationBundle::load(const std::string& path) {
+ if (path.empty()) {
+ factors_by_combo_.clear();
+ return;
+ }
+ factors_by_combo_ = load_channel_traces(path, "S21 object calibration");
+}
+
+void S21ObjectCalibrationBundle::validate_combos(const std::vector& combos) const {
+ validate_bundle_combos(factors_by_combo_, combos, "S21 object calibration bundle");
+}
+
+void S21ObjectCalibrationBundle::apply(ipc::SweepTraceBlock& trace) const {
+ if (factors_by_combo_.empty()) { return; }
+ const auto& factors = resolve_required(factors_by_combo_, trace.combo, "S21 object calibration");
+ ensure_frequency_axis_match(factors.frequency_hz, trace.frequency_hz, "S21 object calibration");
+ if (trace.s21.size() != factors.samples.size()) {
+ throw std::runtime_error("S21 object calibration vector size mismatch");
+ }
+ for (std::size_t index = 0; index < trace.s21.size(); ++index) {
+ const auto value = trace.s21[index];
+ const auto factor = factors.samples[index];
+ const auto corrected = std::complex(value.re, value.im) * std::complex(factor.re, factor.im);
+ if (!std::isfinite(corrected.real()) || !std::isfinite(corrected.imag()) ||
+ std::abs(corrected.real()) > std::numeric_limits::max() ||
+ std::abs(corrected.imag()) > std::numeric_limits::max()) {
+ throw std::runtime_error("S21 object calibration result must fit finite float32");
+ }
+ trace.s21[index] = {.re = static_cast(corrected.real()), .im = static_cast(corrected.imag())};
+ }
+}
+
+} // namespace radar::preprocessing
diff --git a/data_acq_and_processing/preprocessing/calibration_master/include/calibration_master.hpp b/data_acq_and_processing/preprocessing/calibration_master/include/calibration_master.hpp
deleted file mode 100644
index 5508d1a..0000000
--- a/data_acq_and_processing/preprocessing/calibration_master/include/calibration_master.hpp
+++ /dev/null
@@ -1,51 +0,0 @@
-#pragma once
-
-#include
-#include
-#include
-#include
-
-#include "calibrator_interface.hpp"
-#include "channel_bundle_support.hpp"
-#include "shared_types.hpp"
-
-namespace radar::preprocessing {
-
-class CalibrationMaster {
- public:
- // `s21_calibrator` encapsulates the S21 calibration algorithm.
- explicit CalibrationMaster(std::unique_ptr s21_calibrator);
-
- // Loads a serialized raw sweep bundle with one S21 calibration standard per combo.
- void load_s21_calibration_bundle(const std::string& path);
- // Loads optional one-port OSL calibration data for S11 from raw sweep bundles.
- void load_s11_calibration_bundle(
- const std::string& open_path,
- const std::string& short_path,
- const std::string& load_path
- );
- // Ensures all runtime combos are present in loaded standards.
- void validate_combos(const std::vector& combos) const;
- // Applies both channel calibrations to one measured trace.
- [[nodiscard]] auto apply_to_trace(const ipc::SweepTraceBlock& measured_trace) const -> ipc::SweepTraceBlock;
- [[nodiscard]] auto apply_s21(
- const ipc::ComboKey& combo,
- std::span frequency_hz,
- const std::vector& measured_s21
- ) const -> std::vector;
- [[nodiscard]] auto apply_s11(
- const ipc::ComboKey& combo,
- std::span frequency_hz,
- const std::vector& measured_s11
- ) const -> std::vector;
- [[nodiscard]] auto has_s11_calibration() const noexcept -> bool;
-
- private:
- std::unique_ptr s21_calibrator_;
- S21CalibrationBundle s21_calibration_bundle_{};
- S11CalibrationBundle s11_calibration_bundle_{};
-};
-
-[[nodiscard]] auto make_s21_through_calibrator() -> std::unique_ptr;
-
-} // namespace radar::preprocessing
diff --git a/data_acq_and_processing/preprocessing/calibration_master/include/calibrator_interface.hpp b/data_acq_and_processing/preprocessing/calibration_master/include/calibrator_interface.hpp
deleted file mode 100644
index 159f9a5..0000000
--- a/data_acq_and_processing/preprocessing/calibration_master/include/calibrator_interface.hpp
+++ /dev/null
@@ -1,21 +0,0 @@
-#pragma once
-
-#include
-#include
-
-#include "shared_types.hpp"
-
-namespace radar::preprocessing {
-
-class CalibratorInterface {
- public:
- virtual ~CalibratorInterface() = default;
-
- [[nodiscard]] virtual auto name() const -> std::string = 0;
- [[nodiscard]] virtual auto apply(
- const std::vector& measured,
- const std::vector& calibration
- ) const -> std::vector = 0;
-};
-
-} // namespace radar::preprocessing
diff --git a/data_acq_and_processing/preprocessing/calibration_master/include/channel_bundle_support.hpp b/data_acq_and_processing/preprocessing/calibration_master/include/channel_bundle_support.hpp
deleted file mode 100644
index a4c8a03..0000000
--- a/data_acq_and_processing/preprocessing/calibration_master/include/channel_bundle_support.hpp
+++ /dev/null
@@ -1,106 +0,0 @@
-#pragma once
-
-#include
-#include
-#include
-#include
-
-#include "shared_types.hpp"
-
-namespace radar::preprocessing {
-
-class CalibratorInterface;
-
-struct ChannelTrace {
- std::vector frequency_hz{};
- std::vector samples{};
-};
-
-void validate_channel_trace_layout(const ChannelTrace& trace, const std::string& label, const ipc::ComboKey& combo);
-[[nodiscard]] auto frequency_axes_match(std::span left, std::span right) -> bool;
-
-class S21CalibrationBundle {
- public:
- void load(const std::string& path);
-
- [[nodiscard]] auto is_enabled() const noexcept -> bool;
- void validate_combos(const std::vector& combos) const;
- [[nodiscard]] auto apply(
- const ipc::ComboKey& combo,
- std::span frequency_hz,
- const std::vector& measured,
- const CalibratorInterface& calibrator
- ) const -> std::vector;
-
- private:
- [[nodiscard]] auto resolve(const ipc::ComboKey& combo) const -> const ChannelTrace&;
-
- std::unordered_map traces_by_combo_{};
-};
-
-class S21ReferenceBundle {
- public:
- void load(const std::string& path);
-
- [[nodiscard]] auto is_enabled() const noexcept -> bool;
- void validate_combos(const std::vector& combos) const;
- [[nodiscard]] auto resolve(const ipc::ComboKey& combo) const -> const ChannelTrace&;
-
- private:
- std::unordered_map traces_by_combo_{};
-};
-
-// Object-derived MIMO correction, exported on each pair's runtime frequency grid.
-// An empty path disables it; enabled bundles must cover every active pair.
-class S21ObjectCalibrationBundle {
- public:
- void load(const std::string& path);
- void validate_combos(const std::vector& combos) const;
- void apply(ipc::SweepTraceBlock& trace) const;
-
- private:
- std::unordered_map factors_by_combo_{};
-};
-
-class S11CalibrationBundle {
- public:
- struct Coefficients {
- std::vector frequency_hz{};
- std::vector directivity{};
- std::vector source_match{};
- std::vector reflection_tracking{};
- };
-
- void load(
- const std::string& open_path,
- const std::string& short_path,
- const std::string& load_path
- );
-
- [[nodiscard]] auto is_enabled() const noexcept -> bool;
- void validate_combos(const std::vector& combos) const;
- [[nodiscard]] auto apply(
- const ipc::ComboKey& combo,
- std::span frequency_hz,
- const std::vector& measured
- ) const -> std::vector;
-
- private:
- [[nodiscard]] auto resolve(const ipc::ComboKey& combo) const -> const Coefficients&;
-
- std::unordered_map coefficients_by_combo_{};
-};
-
-class S11ReferenceBundle {
- public:
- void load(const std::string& path);
-
- [[nodiscard]] auto is_enabled() const noexcept -> bool;
- void validate_combos(const std::vector& combos) const;
- [[nodiscard]] auto resolve(const ipc::ComboKey& combo) const -> const ChannelTrace&;
-
- private:
- std::unordered_map traces_by_combo_{};
-};
-
-} // namespace radar::preprocessing
diff --git a/data_acq_and_processing/preprocessing/calibration_master/src/calibration_master.cpp b/data_acq_and_processing/preprocessing/calibration_master/src/calibration_master.cpp
deleted file mode 100644
index 0e5e126..0000000
--- a/data_acq_and_processing/preprocessing/calibration_master/src/calibration_master.cpp
+++ /dev/null
@@ -1,63 +0,0 @@
-#include "calibration_master.hpp"
-
-#include
-
-namespace radar::preprocessing {
-
-CalibrationMaster::CalibrationMaster(std::unique_ptr s21_calibrator)
- : s21_calibrator_(std::move(s21_calibrator)) {
- if (!s21_calibrator_) {
- throw std::runtime_error("CalibrationMaster requires a non-null calibrator");
- }
-}
-
-void CalibrationMaster::load_s21_calibration_bundle(const std::string& path) {
- s21_calibration_bundle_.load(path);
-}
-
-void CalibrationMaster::load_s11_calibration_bundle(
- const std::string& open_path,
- const std::string& short_path,
- const std::string& load_path
-) {
- s11_calibration_bundle_.load(open_path, short_path, load_path);
-}
-
-void CalibrationMaster::validate_combos(const std::vector& combos) const {
- s21_calibration_bundle_.validate_combos(combos);
- s11_calibration_bundle_.validate_combos(combos);
-}
-
-auto CalibrationMaster::apply_to_trace(const ipc::SweepTraceBlock& measured_trace) const -> ipc::SweepTraceBlock {
- ipc::SweepTraceBlock output{};
- output.combo = measured_trace.combo;
- output.frequency_hz = measured_trace.frequency_hz;
- output.s21 = apply_s21(measured_trace.combo, measured_trace.frequency_hz, measured_trace.s21);
- output.s11 = apply_s11(measured_trace.combo, measured_trace.frequency_hz, measured_trace.s11);
- // Calibration reshapes the samples, not when they were measured.
- output.capture_start_ns = measured_trace.capture_start_ns;
- output.capture_end_ns = measured_trace.capture_end_ns;
- return output;
-}
-
-auto CalibrationMaster::apply_s21(
- const ipc::ComboKey& combo,
- std::span frequency_hz,
- const std::vector& measured_s21
-) const -> std::vector {
- return s21_calibration_bundle_.apply(combo, frequency_hz, measured_s21, *s21_calibrator_);
-}
-
-auto CalibrationMaster::apply_s11(
- const ipc::ComboKey& combo,
- std::span frequency_hz,
- const std::vector& measured_s11
-) const -> std::vector {
- return s11_calibration_bundle_.apply(combo, frequency_hz, measured_s11);
-}
-
-auto CalibrationMaster::has_s11_calibration() const noexcept -> bool {
- return s11_calibration_bundle_.is_enabled();
-}
-
-} // namespace radar::preprocessing
diff --git a/data_acq_and_processing/preprocessing/calibration_master/src/channel_bundle_support.cpp b/data_acq_and_processing/preprocessing/calibration_master/src/channel_bundle_support.cpp
deleted file mode 100644
index a130748..0000000
--- a/data_acq_and_processing/preprocessing/calibration_master/src/channel_bundle_support.cpp
+++ /dev/null
@@ -1,494 +0,0 @@
-#include "channel_bundle_support.hpp"
-
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-#include "calibrator_interface.hpp"
-
-namespace radar::preprocessing {
-namespace {
-
-enum class RawTraceChannel {
- S11,
- S21,
-};
-
-constexpr float kFrequencyRelativeTolerance = 1e-5F;
-constexpr float kFrequencyAbsoluteTolerance = 1e-3F;
-constexpr float kComplexMagnitudeSquaredEpsilon = 1e-12F;
-// #30: OSL denominator must be well conditioned relative to the magnitudes of the terms forming it.
-constexpr float kOslRelativeConditioning = 1e-6F;
-// #30: maximum fraction of degenerate (fallback) OSL points tolerated per combo before load fails.
-constexpr float kOslMaxDegenerateFraction = 0.05F;
-
-[[nodiscard]] auto combo_to_string(const ipc::ComboKey& combo) -> std::string {
- return "input=" + std::to_string(combo.input_pos) + " output=" + std::to_string(combo.output_pos);
-}
-
-[[nodiscard]] auto read_binary_file(const std::string& path, const std::string& bundle_label)
- -> std::vector {
- std::ifstream stream(path, std::ios::binary);
- if (!stream.is_open()) {
- throw std::runtime_error("Failed to open " + bundle_label + " bundle: " + path);
- }
-
- return std::vector(std::istreambuf_iterator(stream), std::istreambuf_iterator());
-}
-
-void validate_raw_trace_layout(const ipc::SweepTraceBlock& trace, const std::string& trace_label) {
- if (trace.frequency_hz.size() != trace.s11.size()) {
- throw std::runtime_error(
- trace_label + " frequency/S11 vector size mismatch for combo " + combo_to_string(trace.combo)
- );
- }
- if (trace.frequency_hz.size() != trace.s21.size()) {
- throw std::runtime_error(
- trace_label + " frequency/S21 vector size mismatch for combo " + combo_to_string(trace.combo)
- );
- }
-}
-
-} // namespace
-
-void validate_channel_trace_layout(
- const ChannelTrace& trace,
- const std::string& trace_label,
- const ipc::ComboKey& combo
-) {
- if (trace.frequency_hz.size() != trace.samples.size()) {
- throw std::runtime_error(
- trace_label + " frequency/channel vector size mismatch for combo " + combo_to_string(combo)
- );
- }
- for (std::size_t i = 0; i < trace.samples.size(); ++i) {
- if (!std::isfinite(trace.frequency_hz[i]) || !std::isfinite(trace.samples[i].re)
- || !std::isfinite(trace.samples[i].im)) {
- throw std::runtime_error(trace_label + " contains non-finite values for combo " + combo_to_string(combo));
- }
- }
-}
-
-[[nodiscard]] auto frequency_axes_match(std::span left, std::span right) -> bool {
- if (left.size() != right.size()) {
- return false;
- }
-
- for (std::size_t index = 0; index < left.size(); ++index) {
- if (!std::isfinite(left[index]) || !std::isfinite(right[index])) {
- return false;
- }
- const auto delta = std::fabs(left[index] - right[index]);
- const auto scale = std::max(std::fabs(left[index]), std::fabs(right[index]));
- const auto tolerance = std::max(kFrequencyAbsoluteTolerance, scale * kFrequencyRelativeTolerance);
- if (delta > tolerance) {
- return false;
- }
- }
- return true;
-}
-
-namespace {
-
-void ensure_frequency_axis_match(
- std::span expected,
- std::span actual,
- const std::string& label
-) {
- if (!frequency_axes_match(expected, actual)) {
- throw std::runtime_error(label + " frequency axis mismatch");
- }
-}
-
-[[nodiscard]] auto to_std_complex(const ipc::Complex32& value) -> std::complex {
- return std::complex(value.re, value.im);
-}
-
-[[nodiscard]] auto from_std_complex(const std::complex& value) -> ipc::Complex32 {
- return ipc::Complex32{
- .re = value.real(),
- .im = value.imag(),
- };
-}
-
-[[nodiscard]] auto pick_channel_samples(const ipc::SweepTraceBlock& trace, RawTraceChannel channel)
- -> const std::vector& {
- if (channel == RawTraceChannel::S11) {
- return trace.s11;
- }
- return trace.s21;
-}
-
-[[nodiscard]] auto to_channel_trace(
- const ipc::SweepTraceBlock& trace,
- RawTraceChannel channel,
- const std::string& bundle_label
-) -> ChannelTrace {
- validate_raw_trace_layout(trace, bundle_label + " trace");
- if (trace.frequency_hz.empty()) {
- throw std::runtime_error(bundle_label + " trace contains no frequency samples");
- }
-
- ChannelTrace channel_trace{};
- channel_trace.frequency_hz = trace.frequency_hz;
- channel_trace.samples = pick_channel_samples(trace, channel);
- validate_channel_trace_layout(channel_trace, bundle_label + " trace", trace.combo);
- return channel_trace;
-}
-
-[[nodiscard]] auto load_channel_traces(
- const std::string& path,
- const std::string& bundle_label,
- RawTraceChannel channel
-) -> std::unordered_map {
- const auto bytes = read_binary_file(path, bundle_label);
- if (bytes.empty()) {
- throw std::runtime_error(bundle_label + " bundle is empty: " + path);
- }
-
- const auto collection = ipc::deserialize_raw_collection(bytes);
- std::unordered_map