Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
+66 -424
View File
@@ -1,33 +1,20 @@
"""Object-based MIMO S21 corrections estimated after standard preprocessing.
Archives retain the standalone script's cal_*.npy/cal_meta.json layout. Pair
corrections multiply S21 after through calibration, reference subtraction and
notch filtering; they never alter S11 or the original measurement files.
"""
"""Estimate object-based MIMO S21 factors after through/reference preprocessing."""
from __future__ import annotations
from contextlib import contextmanager
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass
from collections.abc import Callable, Iterable
from dataclasses import asdict, dataclass
import copy
import datetime
import json
import logging
from pathlib import Path
import re
import shutil
import tempfile
from typing import Any
import numpy as np
from numpy.typing import ArrayLike
from python_app.file_io import atomic_write_json
from python_app.models.dataset_model import SweepCollection
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.storage.npz.serialize import validate_trace_collection
from python_app.workflows.object_calibration import geometry_signature
C_LIGHT = 299_792_458.0
_CAL_ARRAYS = ('freq_hz', 'pair', 'tx', 'rx', 'valid', 'tx_output_pos', 'rx_input_pos', 'antenna_params')
logger = logging.getLogger(__name__)
def _ports(values, label):
@@ -59,7 +46,7 @@ def _frequency(value, *, uniform=False):
@dataclass
class Settings:
"""Target geometry and estimation options, independent of example globals."""
"""Reflector geometry and phase-estimation settings."""
sphere_radius_m: float = .16
input_idx: tuple[int, ...] = (0, 1, 2, 3)
output_idx: tuple[int, ...] = (0, 1, 2, 3)
@@ -95,34 +82,10 @@ class Settings:
raise ValueError('apply_amplitude must be boolean')
def find_config_profile(start: str | Path, max_levels: int = 8) -> Path:
"""Locate the archived acquisition profile above a stage or snapshot folder."""
path = Path(start)
for _ in range(max_levels):
candidate = path / 'config_profile.json'
if candidate.is_file():
return candidate
path = path.parent
raise FileNotFoundError(f'config_profile.json not found above {start}')
def _profile(path):
config = json.loads(Path(path).read_text(encoding='utf-8'))
if not isinstance(config, dict):
raise ValueError('config_profile.json must contain an object')
return config
def _geometry(config, input_idx, output_idx):
result = []
geometry, result = geometry_signature(config), []
for key, identity, ports in (('tx_geometry', 'output_pos', output_idx), ('rx_geometry', 'input_pos', input_idx)):
entries = config.get('gpr', {}).get(key, [])
mapping = {}
for entry in entries:
port = _ports([entry[identity]], identity)[0]
if port in mapping:
raise ValueError(f'duplicate antenna geometry for {identity}={port}')
mapping[port] = _finite_array([entry['x_m'], entry.get('y_m', 0.), entry.get('z_m', 0.)], key, (3,))
mapping = {entry[identity]: [entry['x_m'], entry['y_m'], entry['z_m']] for entry in geometry[key]}
missing = set(ports) - mapping.keys()
if missing:
raise ValueError(f'{key} is missing physical ports {sorted(missing)}')
@@ -130,268 +93,8 @@ def _geometry(config, input_idx, output_idx):
return tuple(result)
def load_geometry(config_path: str | Path, input_idx: Sequence[int], output_idx: Sequence[int]) -> tuple[np.ndarray, np.ndarray]:
"""Read TX/RX radar coordinates in sorted physical port order."""
return _geometry(_profile(config_path), sorted(_ports(input_idx, 'input_idx')), sorted(_ports(output_idx, 'output_idx')))
def list_snapshots(root: str | Path, prefixes: Sequence[str] | None = None) -> list[Path]:
"""Select complete snapshot directories, requiring unambiguous prefixes."""
directories = sorted(path for path in Path(root).iterdir() if path.is_dir() and (path / 'meta.json').is_file())
if prefixes is None:
selected = directories
else:
selected = []
for prefix in prefixes:
matches = [path for path in directories if path.name.startswith(str(prefix))]
if len(matches) != 1 or matches[0] in selected:
raise ValueError(f'snapshot prefix {prefix!r} must select exactly one distinct snapshot in {root}')
selected.extend(matches)
if not selected:
raise ValueError(f'no snapshots selected in {root}')
return selected
def load_s21(snapshot_dir: str | Path, input_idx: Sequence[int], output_idx: Sequence[int]) -> tuple[np.ndarray, np.ndarray]:
"""Read a finite TX×RX×frequency matrix on one shared uniform grid."""
inputs = sorted(_ports(input_idx, 'input_idx'))
outputs = sorted(_ports(output_idx, 'output_idx'))
rows, freq = [], None
for output in outputs:
row = []
for input_ in inputs:
suffix = f'i{input_}_o{output}'
samples = np.load(Path(snapshot_dir) / f'{suffix}_s21.npy', allow_pickle=False)
axis = _frequency(np.load(Path(snapshot_dir) / f'{suffix}_freq.npy', allow_pickle=False), uniform=True)
if freq is not None and not np.array_equal(axis, freq):
raise ValueError(f'frequency grids differ between pairs in {snapshot_dir}')
freq = axis
row.append(_finite_array(samples, suffix, freq.shape, complex_values=True))
rows.append(row)
assert freq is not None
return np.array(rows), freq
def _source_signature(config) -> dict[str, Any]:
radar = config.get('radar', {})
s21 = config.get('preprocess', {}).get('s21', {})
return {
'radar': {key: radar[key] for key in ('model', 'serial') if key in radar},
'slave_serials': radar.get('multi_device', {}).get('slave_serials', []),
's21_calibration_set': s21.get('calibration', {}).get('set_name', ''),
's21_reference_set': s21.get('reference', {}).get('set_name', ''),
'notch': config.get('preprocess', {}).get('notch', {}),
}
def validate_calibration(cal: dict[str, Any]) -> None:
"""Validate an archive before saving, applying or exporting runtime factors."""
freq = _frequency(cal['freq_hz'], uniform=True)
tx = _ports(cal['tx_output_pos'], 'tx_output_pos')
rx = _ports(cal['rx_input_pos'], 'rx_input_pos')
if tx != tuple(sorted(tx)) or rx != tuple(sorted(rx)):
raise ValueError('calibration physical ports must be sorted')
T = _finite_array(cal['tx'], 'tx corrections', (len(tx), freq.size), complex_values=True)
R = _finite_array(cal['rx'], 'rx corrections', (len(rx), freq.size), complex_values=True)
pair = _finite_array(cal['pair'], 'pair corrections', (len(tx), len(rx), freq.size), complex_values=True)
if np.any(np.abs(T) == 0) or np.any(np.abs(R) == 0) or np.any(np.abs(pair) == 0):
raise ValueError('calibration corrections must be nonzero')
if not np.allclose(pair, T[:, None, :] * R[None, :, :], rtol=2e-5, atol=1e-7):
raise ValueError('pair corrections disagree with tx/rx corrections')
valid = np.asarray(cal['valid'])
if valid.dtype.kind != 'b' or valid.shape != freq.shape or valid.sum() < 2:
raise ValueError('calibration valid mask must select at least two frequencies')
_finite_array(cal['antenna_params'], 'antenna parameters', (len(tx) + len(rx), 3))
geometry = cal['meta']['geometry']
_finite_array(geometry['tx_xyz_m'], 'TX geometry', (len(tx), 3))
_finite_array(geometry['rx_xyz_m'], 'RX geometry', (len(rx), 3))
def validate_for_config(cal: dict[str, Any], config: dict[str, Any]) -> None:
"""Require physical geometry, combo coverage and a compatible phase base."""
validate_calibration(cal)
tx, rx = _geometry(config, cal['rx_input_pos'], cal['tx_output_pos'])
geometry = cal['meta']['geometry']
if not (np.allclose(tx, geometry['tx_xyz_m'], rtol=0, atol=1e-9) and
np.allclose(rx, geometry['rx_xyz_m'], rtol=0, atol=1e-9)):
raise ValueError('antenna geometry differs from the object calibration')
for combo in config.get('run', {}).get('combos', []):
input_, output = _ports([combo['input']], 'combo input')[0], _ports([combo['output']], 'combo output')[0]
if input_ not in cal['rx_input_pos'] or output not in cal['tx_output_pos']:
raise ValueError(f'object calibration does not cover combo {combo}')
source = cal['meta'].get('source_preprocess', {})
current = _source_signature(config)
for key in ('radar', 'slave_serials', 's21_calibration_set'):
if source.get(key) and current.get(key) and source[key] != current[key]:
raise ValueError(f'object calibration source differs: {key}')
@contextmanager
def _staged_directory(destination, overwrite, *, preserve_existing=False):
destination = Path(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.exists() and (not overwrite or not destination.is_dir() or destination.is_symlink()):
raise FileExistsError(f'{destination} already exists; choose another name or overwrite=True')
stage = Path(tempfile.mkdtemp(prefix=f'.{destination.name}.new-', dir=destination.parent))
backup = None
retain = False
try:
if preserve_existing and destination.exists():
shutil.copytree(destination, stage, dirs_exist_ok=True, symlinks=True)
yield stage
if destination.exists():
if not overwrite:
raise FileExistsError(f'{destination} appeared during save; choose another name or overwrite=True')
backup = Path(tempfile.mkdtemp(prefix=f'.{destination.name}.old-', dir=destination.parent))
backup.rmdir()
destination.replace(backup)
try:
stage.replace(destination)
except BaseException:
if backup is not None:
try:
backup.replace(destination)
except OSError as error:
retain = True
raise RuntimeError(f'calibration rollback failed; originals retained at {backup}, new files at {stage}') from error
raise
finally:
if not retain:
for temporary in (stage, backup):
if temporary is not None and temporary.exists():
try:
shutil.rmtree(temporary)
except OSError:
logger.exception('Could not remove calibration temporary directory %s', temporary)
def save_calibration(cal: dict[str, Any], cal_dir: str | Path, overwrite: bool = False) -> Path:
"""Commit a complete legacy archive, restoring its predecessor on failure."""
validate_calibration(cal)
destination = Path(cal_dir)
if destination.is_dir() and any(destination.glob('cal_*')) and not overwrite:
raise FileExistsError(f'{destination} already contains calibration files; use overwrite=True')
meta: dict[str, Any] = dict(cal['meta'], name=destination.name)
# Older one-target archives used nonstandard JSON NaN for unavailable LOO.
quality = dict(meta.get('quality', {}))
if 'coherence_leave_one_out' in quality:
quality['coherence_leave_one_out'] = [value if value is None or np.isfinite(value) else None for value in quality['coherence_leave_one_out']]
meta['quality'] = quality
with _staged_directory(destination, overwrite or destination.is_dir(), preserve_existing=True) as stage:
for key in _CAL_ARRAYS:
path = stage / f'cal_{key}.npy'
path.unlink(missing_ok=True)
np.save(path, cal[key], allow_pickle=False)
(stage / 'cal_meta.json').unlink(missing_ok=True)
atomic_write_json(stage / 'cal_meta.json', meta)
cal['meta'], cal['dir'] = meta, destination
return destination
def load_calibration(cal_dir: str | Path) -> dict[str, Any]:
"""Load and validate cal_*.npy arrays plus cal_meta.json without pickle."""
directory = Path(cal_dir)
cal: dict[str, Any] = {key: np.load(directory / f'cal_{key}.npy', allow_pickle=False) for key in _CAL_ARRAYS}
cal.update(meta=json.loads((directory / 'cal_meta.json').read_text(encoding='utf-8')), dir=directory)
validate_calibration(cal)
return cal
def calibration_on_grid(cal: dict[str, Any], freq: ArrayLike) -> np.ndarray:
"""Interpolate separable corrections and continue phase smoothly at edges."""
validate_calibration(cal)
freq = _frequency(freq)
fc = np.asarray(cal['freq_hz'])
if np.array_equal(freq, fc):
return cal['pair']
def interp(correction, params):
phase = np.unwrap(np.angle(correction))
out = np.interp(freq, fc, phase)
slope = 2 * np.pi * params[1] / C_LIGHT
for selected, edge in ((freq < fc[0], 0), (freq > fc[-1], -1)):
out[selected] = phase[edge] + slope * (freq[selected] - fc[edge])
return np.interp(freq, fc, np.abs(correction)) * np.exp(1j * out)
n_tx = len(cal['tx_output_pos'])
tx = np.array([interp(value, cal['antenna_params'][index]) for index, value in enumerate(cal['tx'])])
rx = np.array([interp(value, cal['antenna_params'][n_tx + index]) for index, value in enumerate(cal['rx'])])
corrections = (tx[:, None, :] * rx[None, :, :]).astype(np.asarray(cal['pair']).dtype)
_finite_array(corrections, 'interpolated corrections', complex_values=True)
return corrections
def apply_calibration(s21: ArrayLike, freq: ArrayLike, cal: dict[str, Any]) -> np.ndarray:
"""Multiply a TX×RX×frequency S21 matrix by validated pair corrections."""
corrections = calibration_on_grid(cal, freq)
samples = _finite_array(s21, 'S21', corrections.shape, complex_values=True)
return _finite_array(samples * corrections, 'corrected S21', corrections.shape, complex_values=True)
def apply_to_measurement(meas_dir: str | Path, cal: dict[str, Any], overwrite: bool = False) -> tuple[Path, int, int, int]:
"""Stage corrected S21 and copied companion files in postprocessed safely."""
directory = Path(meas_dir)
pre, post = directory / 'preprocessed', directory / 'postprocessed'
if not pre.is_dir():
raise FileNotFoundError(f'no {pre}')
config = _profile(find_config_profile(pre))
_reject_corrected_source(config)
validate_for_config(cal, config)
if post.exists() and overwrite and not (post / 'calibration_applied.json').is_file():
raise FileExistsError(f'{post} has no calibration_applied.json; refusing to replace it')
tx = {int(port): index for index, port in enumerate(cal['tx_output_pos'])}
rx = {int(port): index for index, port in enumerate(cal['rx_input_pos'])}
pattern = re.compile(r'^i(\d+)_o(\d+)_s21\.npy$')
n_snap = n_fix = n_copy = 0
cache = {}
with _staged_directory(post, overwrite) as stage:
for item in sorted(pre.iterdir()):
if item.is_file():
shutil.copy2(item, stage / item.name)
n_copy += 1
continue
out = stage / item.name
out.mkdir()
n_snap += 1
for path in sorted(item.iterdir()):
match = pattern.match(path.name)
if path.is_dir() or match is None:
if path.is_dir():
shutil.copytree(path, out / path.name)
else:
shutil.copy2(path, out / path.name)
n_copy += 1
continue
input_, output = int(match[1]), int(match[2])
if input_ not in rx or output not in tx:
raise ValueError(f'object calibration does not cover {path.name}')
freq = _frequency(np.load(item / f'i{input_}_o{output}_freq.npy', allow_pickle=False))
key = freq.tobytes()
if key not in cache:
cache[key] = calibration_on_grid(cal, freq)
samples = np.load(path, allow_pickle=False)
dtype = samples.dtype
samples = _finite_array(samples, path.name, freq.shape, complex_values=True)
corrected = (samples * cache[key][tx[output], rx[input_]]).astype(dtype)
_finite_array(corrected, 'corrected S21', complex_values=True)
np.save(out / path.name, corrected, allow_pickle=False)
n_fix += 1
atomic_write_json(stage / 'calibration_applied.json', dict(
calibration=cal['meta'].get('name'), calibration_dir=str(cal.get('dir') or ''),
created=datetime.datetime.now().isoformat(timespec='seconds'), source=str(pre),
rule='postprocessed S21 = preprocessed S21 * cal_pair[tx(output_pos), rx(input_pos)]',
frequency_grids=[np.frombuffer(key, dtype=np.float64).tolist() for key in cache]))
return post, n_snap, n_fix, n_copy
def _reject_corrected_source(config):
object_calibration = config.get('preprocess', {}).get('s21', {}).get('object_calibration', {})
if object_calibration.get('set_name') or object_calibration.get('bundle_path'):
raise ValueError('calibration sources must have no object correction; object_calibration is already active')
def _best_phases(Z):
"""Maximize pair _coherence by alternating antenna phases, anchored at TX0/RX0."""
"""Maximize pair coherence by alternating antenna phases, anchored at TX0/RX0."""
t = np.zeros(Z.shape[0])
r = np.zeros(Z.shape[1])
for _ in range(100):
@@ -613,20 +316,12 @@ def _mean_samples(samples, check, expected_freq=None):
return mean, freq
def _settings(settings):
settings = settings or Settings()
settings.__post_init__()
return settings
def _compute(sets, empty, freq, settings, config, config_path, check) -> dict[str, Any]:
def _compute(sets, freq, settings, config, provenance, check) -> tuple[SweepCollection, dict[str, Any]]:
tx, rx = _geometry(config, settings.input_idx, settings.output_idx)
est = _Estimator(settings, tx, rx, freq, check)
for index, record in enumerate(sets):
check(f'Estimating target {index + 1}/{len(sets)}')
data = record.pop('s21')
if empty is not None:
data = data - empty
nominal = _finite_array(record['target_scene_m'], 'target_scene_m', (3,))
echo, refined = est.refine_target(data, est.coarse_range(data, nominal))
if not np.all(np.any(np.abs(echo[:, :, est.ok]) > 0, axis=2)):
@@ -642,57 +337,46 @@ def _compute(sets, empty, freq, settings, config, config_path, check) -> dict[st
antennas.update({f'Rx{index}': est.antenna_params(value) for index, value in enumerate(R_all)})
T_full = np.array([est.to_full_grid(value, antennas[f'Tx{index}']) for index, value in enumerate(T_all)])
R_full = np.array([est.to_full_grid(value, antennas[f'Rx{index}']) for index, value in enumerate(R_all)])
unit = (lambda value: np.conj(value) / np.abs(value)) if not settings.apply_amplitude else (lambda value: 1 / value)
saved_pair = unit(T_full[:, None, :] * R_full[None, :, :]).astype(np.complex64)
quality = dict(coherence_before=[], coherence_own=[], coherence_leave_one_out=[], coherence_final=[])
saved_pair = est.pair_correction(T_full, R_full).astype(np.complex64)
_finite_array(saved_pair, 'pair corrections', complex_values=True)
diagnostics = []
final_correction = saved_pair[:, :, est.band]
for record in sets:
check()
before = est.broadband(record['E'])
after = est.broadband(record['E'] * final_correction)
quality['coherence_before'].append(_coherence(before))
quality['coherence_own'].append(_coherence(est.broadband(record['E'] * est.pair_correction(record['T'], record['R']))))
others = [other for other in sets if other is not record]
quality['coherence_leave_one_out'].append(
_coherence(est.broadband(record['E'] * est.pair_correction(*_combine(others)))) if others else None)
quality['coherence_final'].append(_coherence(after))
diagnostics.append(dict(phase_before_deg=np.degrees(np.angle(before)).tolist(),
phase_after_deg=np.degrees(np.angle(after)).tolist(),
# Notebook cell 5: each map uses its own summed contribution as phase zero.
phase_before = (np.degrees(np.angle(before) - np.angle(before.sum())) + 180) % 360 - 180
phase_after = (np.degrees(np.angle(after) - np.angle(after.sum())) + 180) % 360 - 180
diagnostics.append(dict(phase_before_deg=phase_before.tolist(),
phase_after_deg=phase_after.tolist(),
phase_std_before_deg=float(np.std(phase_before)),
phase_std_after_deg=float(np.std(phase_after)),
coherence_before=_coherence(before), coherence_after=_coherence(after)))
valid = np.zeros(freq.size, bool)
valid[np.flatnonzero(est.band)[est.ok]] = True
meta = dict(
created=datetime.datetime.now().isoformat(timespec='seconds'), name=None,
convention=dict(apply='S21_corrected[tx, rx, f] = S21[tx, rx, f] * cal_pair[tx, rx, f]',
index_order='sorted physical tx_output_pos x rx_input_pos',
phase_reference='relative to Tx0 and Rx0; common phase/delay is retained',
amplitude='equalized' if settings.apply_amplitude else 'phase only',
outside_band='linear antenna phase continuation anchored at the band edge'),
geometry=dict(config=str(config_path), tx_xyz_m=tx.tolist(), rx_xyz_m=rx.tolist(),
antenna_height_m=settings.antenna_height_m, pitch_deg=settings.pitch_deg),
estimation=dict(band_hz=[settings.f_min, settings.f_max], gate_half_m=settings.gate_half_m,
gate_taper_m=settings.gate_taper_m, coarse_range_search_m=settings.coarse_range_search_m,
refine_axes=list(settings.refine_axes), refine_range_m=settings.refine_range_m,
phase_model=settings.phase_model, sphere_radius_m=settings.sphere_radius_m),
source_preprocess=_source_signature(config),
targets=[dict(path=record.get('path', ''), snapshots=record.get('snapshots', []),
nominal_scene_m=record['q_nominal'].tolist(), refined_scene_m=record['q'].tolist()) for record in sets],
antennas=antennas, quality=quality, diagnostics=diagnostics)
cal = dict(freq_hz=freq.astype(np.float64), pair=saved_pair,
tx=unit(T_full).astype(np.complex64), rx=unit(R_full).astype(np.complex64), valid=valid,
tx_output_pos=np.array(settings.output_idx, np.int32), rx_input_pos=np.array(settings.input_idx, np.int32),
antenna_params=np.array([[np.radians(value['phase0_deg']), value['delay_path_m'], value['gain_db']]
for value in antennas.values()]), meta=meta, dir=None)
validate_calibration(cal)
metadata = dict(
created=datetime.datetime.now().isoformat(timespec='seconds'),
provenance=copy.deepcopy(provenance),
tx_output_pos=list(settings.output_idx), rx_input_pos=list(settings.input_idx),
geometry=dict(tx_xyz_m=tx.tolist(), rx_xyz_m=rx.tolist()),
settings=asdict(settings),
source_preprocess=dict(reference_name=config.get('preprocess', {}).get('s21', {}).get('reference', {}).get('set_name', ''),
notch=copy.deepcopy(config.get('preprocess', {}).get('notch', {}))),
targets=[dict(captures=record['captures'], nominal_scene_m=record['q_nominal'].tolist(),
refined_scene_m=record['q'].tolist()) for record in sets],
antennas=antennas, diagnostics=diagnostics,
)
frequency = freq.astype(np.float32)
collection = SweepCollection(0, 0, [
TraceData(ComboKey(input_, output), frequency, saved_pair[t, r])
for t, output in enumerate(settings.output_idx) for r, input_ in enumerate(settings.input_idx)
])
validate_trace_collection(collection, require_finite=True)
check('Object calibration complete')
return cal
return collection, metadata
def _collection_samples(collection, settings):
from python_app.storage.npz.serialize import validate_trace_collection
validate_trace_collection(collection, require_finite=True)
traces = {(trace.combo.input, trace.combo.output): trace for trace in collection.traces}
rows, freq = [], None
@@ -712,81 +396,39 @@ def _collection_samples(collection, settings):
def compute_from_collections(
cal_sets: Iterable[dict[str, Any]], config_dict: dict[str, Any],
empty_collections: Iterable[SweepCollection] | None = None, settings: Settings | None = None,
*, progress: Callable[[str], None] | None = None, cancelled: Callable[[], bool] | None = None,
) -> dict[str, Any]:
"""Estimate corrections from standard-preprocessed sweeps without object correction.
cal_sets: Iterable[dict[str, Any]], config_dict: dict[str, Any], settings: Settings | None = None,
*, provenance: dict[str, Any], progress: Callable[[str], None] | None = None,
cancelled: Callable[[], bool] | None = None,
) -> tuple[SweepCollection, dict[str, Any]]:
"""Estimate a saved set from preprocessed sweeps without object correction.
cal_sets contains {collections: [SweepCollection, ...], target_scene_m: XYZ}.
progress accepts a status string; cancelled returns bool and raises
InterruptedError promptly in data, refinement and factorization loops.
Each target provides ``collections`` and ground-relative ``target_scene_m``.
Reference subtraction has already been applied by the live preprocessor.
Cancellation is checked during averaging, refinement and factorization.
"""
settings = _settings(settings)
_reject_corrected_source(config_dict)
settings = Settings() if settings is None else settings
check = _callbacks(progress, cancelled)
sets = list(cal_sets)
if not sets:
raise ValueError('at least one reflector target is required')
empty, freq = None, None
if empty_collections is not None:
check('Averaging empty scene')
empty, freq = _mean_samples((_collection_samples(value, settings) for value in empty_collections), check)
prepared = []
prepared, freq = [], None
for index, record in enumerate(sets):
check(f'Averaging target {index + 1}/{len(sets)}')
collections = list(record['collections'])
mean, freq = _mean_samples((_collection_samples(value, settings) for value in collections), check, freq)
prepared.append(dict(s21=mean, target_scene_m=record['target_scene_m'],
snapshots=[str(value.collection_id) for value in collections]))
return _compute(prepared, empty, freq, settings, config_dict, 'live capture', check)
def compute_calibration(
cal_sets: Iterable[dict[str, Any]], empty_scene_path: str | Path | None = None, settings: Settings | None = None,
*, progress: Callable[[str], None] | None = None, cancelled: Callable[[], bool] | None = None,
) -> dict[str, Any]:
"""Legacy archive API backed by the same estimator as live collection capture."""
settings = _settings(settings)
check = _callbacks(progress, cancelled)
cal_sets = list(cal_sets)
if not cal_sets:
raise ValueError('at least one reflector target is required')
config_path = find_config_profile(cal_sets[0]['path'])
config = _profile(config_path)
_reject_corrected_source(config)
geometry = _geometry(config, settings.input_idx, settings.output_idx)
signature = _source_signature(config)
def samples(directories):
for directory in directories:
check()
profile_path = find_config_profile(directory)
path = Path(directory)
while True:
if (path / 'calibration_applied.json').is_file():
raise ValueError(f'object calibration is already applied to archive {directory}')
if path == profile_path.parent:
break
path = path.parent
source = _profile(profile_path)
_reject_corrected_source(source)
current = _geometry(source, settings.input_idx, settings.output_idx)
if any(not np.allclose(first, second, rtol=0, atol=1e-9) for first, second in zip(geometry, current)):
raise ValueError(f'antenna geometry differs in {directory}')
if signature != _source_signature(source):
raise ValueError(f'preprocessing or hardware configuration differs in {directory}')
yield load_s21(directory, settings.input_idx, settings.output_idx)
empty, freq = None, None
if empty_scene_path is not None:
empty, freq = _mean_samples(samples(list_snapshots(empty_scene_path)), check)
prepared = []
for record in cal_sets:
directories = list_snapshots(record['path'], record.get('snapshots'))
mean, freq = _mean_samples(samples(directories), check, freq)
prepared.append(dict(s21=mean, target_scene_m=record['target_scene_m'], path=str(record['path']),
snapshots=[directory.name for directory in directories]))
cal = _compute(prepared, empty, freq, settings, config, config_path, check)
cal['meta']['estimation']['empty_scene'] = str(empty_scene_path) if empty_scene_path is not None else None
return cal
prepared.append(dict(s21=mean, target_scene_m=record['target_scene_m'], captures=[
dict(collection_id=value.collection_id, monotonic_ns=value.monotonic_ns,
capture_start_ns=value.capture_start_ns, capture_end_ns=value.capture_end_ns)
for value in collections
]))
collection, metadata = _compute(prepared, freq, settings, config_dict, provenance, check)
# Multi-device acquisition keeps every stage for timing. Pairs outside the
# configured geometry carry identity factors, explicitly marked in the set.
corrected = {trace.combo for trace in collection.traces}
unchanged = [trace for trace in collections[0].traces if trace.combo not in corrected]
collection.traces.extend(TraceData(
trace.combo, np.asarray(trace.frequency_hz, dtype=np.float32),
np.ones(trace.frequency_hz.size, dtype=np.complex64),
) for trace in unchanged)
metadata['unchanged_combos'] = [dict(input=trace.combo.input, output=trace.combo.output) for trace in unchanged]
return collection, metadata
@@ -1,361 +0,0 @@
"""Sequential capture workflow that captures each combo across multiple radar sweep variants."""
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass
import logging
import time
from typing import cast
from python_app.hardware_full.matrix_radar_service import MatrixRadarService, create_matrix_radar_service
from python_app.hardware_full.single_radar_service import SingleRadarService, create_single_radar_service
from python_app.hardware_full.switch_service import SwitchService
from python_app.models.dataset_model import SweepCollection, TraceData
from python_app.models.run_config_model import ComboModel, RunConfigModel
from python_app.storage.npz_store import NpzStore
from python_app.workflows.radar_config_variants import RadarConfigVariant
from python_app.workflows.sequential_capture_workflow import (
MATRIX_RADAR_MANUAL_CAPTURE_KINDS,
SequentialCaptureState,
acquire_matrix_combo_collection,
capture_single_radar_combo,
combine_collections_via_median,
combine_traces_via_median,
select_trace_for_combo,
trace_capture_window,
)
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class MultiRadarCaptureBatch:
"""One completed combo capture across all configured radar variants."""
combo: ComboModel
traces: tuple[TraceData, ...]
variant_labels: tuple[str, ...]
@property
def display_trace(self) -> TraceData:
"""Return the last trace in the batch for preview rendering."""
return self.traces[-1]
@dataclass(frozen=True, slots=True)
class MultiRadarSavedSet:
"""One preprocess set persisted for one radar variant."""
display_name: str
radar_key: str
trace_count: int
class MultiRadarSequentialCaptureSession:
"""Capture a full combo matrix for one preprocess asset across multiple radar sweep variants."""
def __init__(
self,
*,
base_config: RunConfigModel,
kind: str,
set_name: str,
radar_variants: list[RadarConfigVariant],
median_sweep_count: int = 1,
) -> None:
"""Create capture session for one preprocess asset set and multiple radar variants."""
if kind not in {"s21_calibration", "s21_reference", "s11_open", "s11_short", "s11_load", "s11_reference"}:
raise RuntimeError(f"Unsupported capture kind: {kind}")
if not set_name:
raise RuntimeError("Set name is required")
if not radar_variants:
raise RuntimeError("At least one radar variant is required")
if int(median_sweep_count) < 1:
raise RuntimeError("median_sweep_count must be >= 1")
self._base_config = base_config
self._kind = kind
self._set_name = set_name
self._radar_variants = list(radar_variants)
self._median_sweep_count = int(median_sweep_count)
self._is_matrix_radar = base_config.is_matrix_radar
self._manual_matrix_radar_capture = (
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
)
self._combos = base_config.build_runtime_combos()
if not self._combos:
raise RuntimeError("No switch combinations available for capture")
self._captured_batches: list[MultiRadarCaptureBatch] = []
self._traces_by_radar_key = {
variant.radar_key: []
for variant in self._radar_variants
}
self._next_index = 0
self._opened = False
logger.info(
"Multi-radar capture session created: kind=%s set=%s combos=%d variants=%d matrix_radar=%s",
self._kind,
self._set_name,
len(self._combos),
len(self._radar_variants),
self._is_matrix_radar,
)
self._radar: MatrixRadarService | SingleRadarService
if self._is_matrix_radar:
self._radar = create_matrix_radar_service(base_config)
self._input_switch = None
self._output_switch = None
else:
self._radar = create_single_radar_service(base_config)
self._input_switch = SwitchService.from_model(base_config.input_switch)
self._output_switch = SwitchService.from_model(base_config.output_switch)
@property
def kind(self) -> str:
"""Return canonical preprocess asset key for this capture session."""
return self._kind
@property
def set_name(self) -> str:
"""Return destination set name."""
return self._set_name
def open(self) -> None:
"""Open radar and switch resources."""
if self._opened:
return
self._opened = True
try:
self._radar.open()
self._radar.configure(self._base_config.radar.sweep)
if self._input_switch is not None:
self._input_switch.open()
if self._output_switch is not None:
self._output_switch.open()
logger.info("Multi-radar capture session opened (kind=%s set=%s)", self._kind, self._set_name)
except Exception:
logger.exception(
"Failed to open multi-radar capture session (kind=%s set=%s)", self._kind, self._set_name
)
self.close()
raise
def close(self) -> None:
"""Close all opened hardware resources."""
was_open = self._opened
for resource in (self._output_switch, self._input_switch, self._radar):
if resource is not None:
with suppress(Exception):
resource.close()
self._opened = False
if was_open:
logger.info("Multi-radar capture session closed (kind=%s set=%s)", self._kind, self._set_name)
def state(self) -> SequentialCaptureState:
"""Return current progress snapshot."""
current_combo = self._current_combo()
return SequentialCaptureState(
kind=self._kind,
set_name=self._set_name,
captured_count=(
self._next_index
if self._is_matrix_radar and not self._manual_matrix_radar_capture
else len(self._captured_batches)
),
total_count=len(self._combos),
current_combo=current_combo,
can_undo=bool(self._captured_batches),
is_complete=self.is_complete(),
variant_count=len(self._radar_variants),
supports_batch_capture=not self._manual_matrix_radar_capture,
)
def capture_current_combo(self) -> MultiRadarCaptureBatch:
"""Capture the current combo across all radar variants and advance the combo cursor."""
if not self._opened:
raise RuntimeError("Capture session is not opened")
combo = self._current_combo()
if combo is None:
raise RuntimeError("Capture session is already complete")
pending_traces_by_radar_key: dict[str, list[TraceData]] = {}
display_traces: list[TraceData] = []
variant_labels: list[str] = []
if self._is_matrix_radar:
radar = cast(MatrixRadarService, self._radar)
for variant in self._radar_variants:
self._radar.configure(variant.config.radar.sweep)
if self._base_config.runtime.settling_ms > 0:
time.sleep(self._base_config.runtime.settling_ms / 1000.0)
if self._manual_matrix_radar_capture:
# Only this combo is kept, so acquire the smallest collection
# that contains it instead of the full (switch-widened) matrix.
per_sweep_traces = [
select_trace_for_combo(
acquire_matrix_combo_collection(radar, combo), combo
)
for _ in range(self._median_sweep_count)
]
trace = combine_traces_via_median(per_sweep_traces)
pending_traces_by_radar_key[variant.radar_key] = [trace]
display_traces.append(trace)
else:
collections: list[SweepCollection] = []
for _ in range(self._median_sweep_count):
collection = radar.acquire_collection(collection_id=1)
if not collection.traces:
raise RuntimeError(
f"Matrix radar variant {variant.display_name} returned no traces"
)
collections.append(collection)
combined_collection = combine_collections_via_median(collections)
pending_traces_by_radar_key[variant.radar_key] = list(combined_collection.traces)
display_traces.append(combined_collection.traces[-1])
variant_labels.append(variant.display_name)
else:
if self._input_switch is None or self._output_switch is None:
raise RuntimeError("Switches are not initialised for combo capture")
self._output_switch.switch_to(combo.output)
self._input_switch.switch_to(combo.input)
if self._base_config.runtime.settling_ms > 0:
time.sleep(self._base_config.runtime.settling_ms / 1000.0)
for variant in self._radar_variants:
self._radar.configure(variant.config.radar.sweep)
if self._base_config.runtime.settling_ms > 0:
time.sleep(self._base_config.runtime.settling_ms / 1000.0)
trace = capture_single_radar_combo(
cast(SingleRadarService, self._radar), combo, self._median_sweep_count,
)
pending_traces_by_radar_key[variant.radar_key] = [trace]
display_traces.append(trace)
variant_labels.append(variant.display_name)
for radar_key, traces in pending_traces_by_radar_key.items():
self._traces_by_radar_key[radar_key].extend(traces)
batch = MultiRadarCaptureBatch(
combo=combo,
traces=tuple(display_traces),
variant_labels=tuple(variant_labels),
)
self._captured_batches.append(batch)
if self._is_matrix_radar and not self._manual_matrix_radar_capture:
self._next_index = len(self._combos)
else:
self._next_index += 1
logger.debug(
"Captured combo input=%d output=%d across %d variant(s) (%d/%d)",
combo.input,
combo.output,
len(variant_labels),
self._next_index,
len(self._combos),
)
return batch
def undo_last_capture(self) -> MultiRadarCaptureBatch:
"""Remove the most recently captured combo batch and rewind the cursor."""
if not self._opened:
raise RuntimeError("Capture session is not opened")
if not self._captured_batches or self._next_index <= 0:
raise RuntimeError("No captured combo is available to undo")
if self._is_matrix_radar and not self._manual_matrix_radar_capture:
removed_batch = self._captured_batches[-1]
for variant in self._radar_variants:
traces = self._traces_by_radar_key[variant.radar_key]
if len(traces) < len(self._combos):
raise RuntimeError("Capture session state is inconsistent; missing matrix radar traces")
del traces[-len(self._combos) :]
self._next_index = 0
self._captured_batches.pop()
return removed_batch
expected_combo = self._combos[self._next_index - 1]
removed_batch = self._captured_batches[-1]
if (
int(removed_batch.combo.input) != int(expected_combo.input)
or int(removed_batch.combo.output) != int(expected_combo.output)
):
raise RuntimeError("Capture session state is inconsistent; last batch does not match rewind combo")
for variant in self._radar_variants:
traces = self._traces_by_radar_key[variant.radar_key]
if not traces:
raise RuntimeError("Capture session state is inconsistent; missing trace during undo")
traces.pop()
self._next_index -= 1
self._captured_batches.pop()
return removed_batch
def last_captured_trace(self) -> TraceData | None:
"""Return the most recent trace from the most recent combo batch, if any."""
if not self._captured_batches:
return None
return self._captured_batches[-1].display_trace
def captured_batches(self) -> list[MultiRadarCaptureBatch]:
"""Return completed combo batches in capture order."""
return list(self._captured_batches)
def traces_for_radar_key(self, radar_key: str) -> list[TraceData]:
"""Return every captured trace (one per combo) for one radar variant.
Unlike a batch's ``traces`` (one entry per variant, holding only the
preview trace), this returns the full per-combo set, so matrix radars
expose all ports rather than just the last one.
"""
return list(self._traces_by_radar_key.get(radar_key, []))
def radar_variant_count(self) -> int:
"""Return how many radar variants are captured per combo."""
return len(self._radar_variants)
def is_complete(self) -> bool:
"""Return `True` when all combos were captured."""
return self._next_index >= len(self._combos)
def finalize(self, store: NpzStore) -> list[MultiRadarSavedSet]:
"""Persist completed captures as one preprocess set per radar variant."""
if not self.is_complete():
raise RuntimeError("Capture session is not complete")
monotonic_ns = time.monotonic_ns()
saved_sets: list[MultiRadarSavedSet] = []
for variant in self._radar_variants:
traces = list(self._traces_by_radar_key[variant.radar_key])
capture_start_ns, capture_end_ns = trace_capture_window(traces)
collection = SweepCollection(
collection_id=1,
monotonic_ns=monotonic_ns,
traces=traces,
capture_start_ns=capture_start_ns,
capture_end_ns=capture_end_ns,
)
store.save_set(self._kind, variant.radar_key, self._set_name, collection)
saved_sets.append(
MultiRadarSavedSet(
display_name=variant.display_name,
radar_key=variant.radar_key,
trace_count=len(traces),
)
)
logger.info(
"Finalized multi-radar capture kind=%s set=%s into %d variant set(s)",
self._kind,
self._set_name,
len(saved_sets),
)
return saved_sets
def _current_combo(self) -> ComboModel | None:
"""Return next combo to capture, or `None` if session is complete."""
if self._next_index >= len(self._combos):
return None
return self._combos[self._next_index]
@@ -1,13 +1,8 @@
"""Neutral preprocessing-set helpers — the "run without calibration" path.
"""Neutral S21 bundles: unit through-calibration and zero reference.
A neutral pair is a calibration set carrying unit S21 (1+0j) and a reference set
carrying zero S21. The C++ through-calibrator divides measured/calibration and the
reference is subtracted, so applying both leaves the measured S21 untouched. That
lets an operator start the pipeline before any real calibration exists, which the
required-asset check in `_start_run` would otherwise refuse.
The shortcut is enabled for verified axes: Kamil ADC's processing grid,
synchronized multi-device LibreVNA, and the physical single LibreVNA mock.
Applying these sets leaves measurements unchanged and satisfies pipeline asset
validation before physical calibration is available. The shortcut is enabled for
verified frequency axes: Kamil ADC, multi-device LibreVNA and single-device mock.
"""
from __future__ import annotations
@@ -22,7 +17,7 @@ from python_app.hardware_full.kamil_adc import (
KamilAdcSweepProcessor,
)
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import ComboModel, RunConfigModel
from python_app.models.run_config_schema import ComboModel, RunConfigModel
logger = logging.getLogger(__name__)
@@ -46,8 +41,7 @@ def neutral_frequency_grid_hz(config: RunConfigModel) -> np.ndarray:
points = int(config.radar.sweep.points)
if points < 1:
raise ValueError("Neutral sets require radar.sweep.points >= 1")
# Mirrors both acquisition paths: the native collector seeds this same linspace
# and the mock backend generates it outright.
# The single/multi-device services and mock use this same linspace.
return np.linspace(
float(config.radar.sweep.start_hz),
float(config.radar.sweep.stop_hz),
@@ -107,7 +101,6 @@ def _neutral_collection(
TraceData(
combo=ComboKey(input=int(combo.input), output=int(combo.output)),
frequency_hz=frequency_hz.copy(),
s11=np.zeros(point_count, dtype=np.complex64),
s21=np.full(point_count, s21_value, dtype=np.complex64),
)
for combo in combos
@@ -0,0 +1,85 @@
"""Object-calibration sets and the acquisition state that makes them applicable."""
from __future__ import annotations
import hashlib
import struct
from typing import Any
import numpy as np
from python_app.models.dataset_model import ComboKey, SweepCollection
from python_app.storage.npz.serialize import validate_trace_collection
def geometry_signature(config: dict[str, Any]) -> dict[str, list[dict[str, int | float]]]:
"""Normalize physical antenna coordinates independently of table row order."""
return {
key: sorted(({
identity: int(entry[identity]),
'x_m': float(entry['x_m']), 'y_m': float(entry.get('y_m', 0.)), 'z_m': float(entry.get('z_m', 0.)),
} for entry in config.get('gpr', {}).get(key, [])), key=lambda record: record[identity])
for key, identity in (("tx_geometry", "output_pos"), ("rx_geometry", "input_pos"))
}
def through_fingerprint(collection: SweepCollection) -> str:
"""Hash physical pairs, float32 frequencies and complex64 S21, excluding timing."""
validate_trace_collection(collection, require_finite=True)
digest = hashlib.sha256()
for trace in sorted(collection.traces, key=lambda item: (item.combo.input, item.combo.output)):
frequency = np.asarray(trace.frequency_hz, dtype="<f4")
samples = np.asarray(trace.s21, dtype="<c8")
digest.update(struct.pack("<III", trace.combo.input, trace.combo.output, frequency.size))
digest.update(frequency.tobytes())
digest.update(samples.tobytes())
return digest.hexdigest()
def capture_provenance(
config: dict[str, Any], radar_key: str, through: SweepCollection,
) -> dict[str, Any]:
"""Freeze the hardware, geometry and through calibration used for a capture."""
return {
"radar_key": radar_key,
"geometry": geometry_signature(config),
"through_name": config["preprocess"]["s21"]["calibration"]["set_name"].strip(),
"through_hash": through_fingerprint(through),
}
def validate_object_set(
collection: SweepCollection,
metadata: dict[str, Any],
config: dict[str, Any],
radar_key: str,
through: SweepCollection,
) -> None:
"""Reject corrections whose acquisition state or selected pair grids differ."""
validate_trace_collection(collection, require_finite=True)
captured = metadata.get("provenance")
if not isinstance(captured, dict):
raise ValueError("Object calibration has no capture provenance; capture it again")
current = capture_provenance(config, radar_key, through)
for field, label in (
("radar_key", "radar configuration"),
("geometry", "antenna geometry"),
("through_name", "through calibration name"),
("through_hash", "through calibration content"),
):
if captured.get(field) != current[field]:
raise ValueError(f"Object calibration {label} differs from the captured state")
corrections = {trace.combo: trace for trace in collection.traces}
through_traces = {trace.combo: trace for trace in through.traces}
for combo in config["run"]["combos"]:
key = ComboKey(input=combo["input"], output=combo["output"])
if key not in corrections or key not in through_traces:
raise ValueError(f"Object calibration and through must cover input={key.input}, output={key.output}")
correction = corrections[key]
if np.any(correction.s21 == 0):
raise ValueError("Object calibration corrections must be nonzero")
if not np.array_equal(
np.asarray(correction.frequency_hz, dtype=np.float32),
np.asarray(through_traces[key].frequency_hz, dtype=np.float32),
):
raise ValueError(f"Object calibration frequency grid differs for input={key.input}, output={key.output}")
+2 -15
View File
@@ -7,8 +7,7 @@ import json
import logging
from pathlib import Path
from python_app.models.run_config_model import RunConfigModel
from python_app.storage.npz_store import radar_key_from_config
from python_app.models.run_config_schema import RunConfigModel
logger = logging.getLogger(__name__)
@@ -25,7 +24,6 @@ _RADAR_SWEEP_KEYS = (
class RadarConfigVariant:
"""One capture-ready radar sweep variant loaded from JSON."""
source_path: Path
display_name: str
config: RunConfigModel
radar_key: str
@@ -147,19 +145,8 @@ def _load_radar_config_variant(path: Path, *, base_config: RunConfigModel) -> Ra
config_payload["radar"]["sweep"].update(sweep_payload)
config = RunConfigModel.from_dict(config_payload)
radar_key = radar_key_from_config(
model_name=config.radar.model,
serial=config.radar.serial,
sweep_start_hz=config.radar.sweep.start_hz,
sweep_stop_hz=config.radar.sweep.stop_hz,
sweep_points=config.radar.sweep.points,
ifbw_hz=config.radar.sweep.if_bandwidth_hz,
power_dbm=config.radar.sweep.power_dbm,
extra_serials=config.radar_key_extra_parts() or None,
)
return RadarConfigVariant(
source_path=path,
display_name=path.stem,
config=config,
radar_key=radar_key,
radar_key=config.radar_key(),
)
+4 -3
View File
@@ -5,8 +5,8 @@ from __future__ import annotations
import logging
from python_app.models.dataset_model import SweepCollection
from python_app.models.run_config_model import RunConfigModel
from python_app.storage.npz_store import NpzStore
from python_app.models.run_config_schema import RunConfigModel
from python_app.storage.npz.store import NpzStore
from python_app.workflows.sequential_capture_workflow import (
DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT,
SequentialCaptureSession,
@@ -34,6 +34,7 @@ def capture_reference_set(
session.open()
while not session.is_complete():
session.capture_current_combo()
return session.finalize(store)
saved = session.finalize(store)[0]
return saved.radar_key, saved.collection
finally:
session.close()
@@ -1,10 +1,9 @@
"""Sequential capture workflow for preprocess asset dataset creation."""
"""One capture session for one or more radar sweep variants."""
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass
import logging
import time
from typing import cast
@@ -12,28 +11,23 @@ import numpy as np
from python_app.hardware_full.matrix_radar_service import MatrixRadarService, create_matrix_radar_service
from python_app.hardware_full.single_radar_service import SingleRadarService, create_single_radar_service
from python_app.hardware_full.switch_service import SwitchService
from python_app.hardware_full.switch_service import SwitchService, select_and_settle
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import ComboModel, RunConfigModel
from python_app.storage.npz_store import NpzStore, radar_key_from_config
from python_app.models.run_config_schema import ComboModel, RunConfigModel
from python_app.storage.npz.store import NpzStore
from python_app.workflows.radar_config_variants import RadarConfigVariant
logger = logging.getLogger(__name__)
MATRIX_RADAR_MANUAL_CAPTURE_KINDS = frozenset({"s21_calibration", "s11_open", "s11_short", "s11_load"})
DEFAULT_CALIBRATION_MEDIAN_SWEEP_COUNT = 5
def trace_capture_window(traces: list[TraceData]) -> tuple[int, int]:
"""Return the total measured window, retaining zero when timing was not reported."""
starts = [int(trace.capture_start_ns) for trace in traces if int(trace.capture_start_ns) > 0]
ends = [int(trace.capture_end_ns) for trace in traces if int(trace.capture_end_ns) > 0]
return min(starts, default=0), max(ends, default=0)
"""Span measured trace windows, retaining zero if timing was not reported."""
return (min((t.capture_start_ns for t in traces if t.capture_start_ns > 0), default=0),
max((t.capture_end_ns for t in traces if t.capture_end_ns > 0), default=0))
@dataclass(slots=True)
class SequentialCaptureState:
"""Snapshot of sequential capture progress for the GUI/controller."""
kind: str
set_name: str
captured_count: int
@@ -45,321 +39,178 @@ class SequentialCaptureState:
supports_batch_capture: bool = True
@dataclass(frozen=True, slots=True)
class CaptureBatch:
combo: ComboModel
traces: tuple[TraceData, ...]
@property
def display_trace(self) -> TraceData:
return self.traces[-1]
@dataclass(frozen=True, slots=True)
class SavedCaptureSet:
display_name: str
radar_key: str
collection: SweepCollection
class SequentialCaptureSession:
"""Manage hardware and switch stepping for full combo capture sequence."""
"""Capture each antenna pair across variants, committing a complete batch atomically."""
def __init__(
self,
config: RunConfigModel,
kind: str,
set_name: str,
*,
median_sweep_count: int = 1,
self, config: RunConfigModel, kind: str, set_name: str, *,
radar_variants: list[RadarConfigVariant] | None = None, median_sweep_count: int = 1,
) -> None:
"""Create capture session for one preprocess asset set."""
if kind not in {"s21_calibration", "s21_reference", "s11_open", "s11_short", "s11_load", "s11_reference"}:
if kind not in {"s21_calibration", "s21_reference"}:
raise RuntimeError(f"Unsupported capture kind: {kind}")
if not set_name:
raise RuntimeError("Set name is required")
if int(median_sweep_count) < 1:
if median_sweep_count < 1:
raise RuntimeError("median_sweep_count must be >= 1")
self._config = config
self._kind = kind
self._set_name = set_name
self._median_sweep_count = int(median_sweep_count)
self._is_matrix_radar = config.is_matrix_radar
self._manual_matrix_radar_capture = (
self._is_matrix_radar and kind in MATRIX_RADAR_MANUAL_CAPTURE_KINDS
)
self.kind, self.set_name = kind, set_name
self._config, self._median_sweep_count = config, median_sweep_count
self._variants = ([RadarConfigVariant("Current radar", config, config.radar_key())]
if radar_variants is None else list(radar_variants))
if not self._variants:
raise RuntimeError("At least one radar variant is required")
self._combos = config.build_runtime_combos()
if not self._combos:
raise RuntimeError("No switch combinations available for capture")
self._traces: list[TraceData] = []
self._next_index = 0
self._full_matrix = config.is_multi_device and kind == "s21_reference"
self._batches: list[CaptureBatch] = []
self._traces: dict[str, list[TraceData]] = {v.radar_key: [] for v in self._variants}
self._opened = False
logger.info(
"Sequential capture session created: kind=%s set=%s combos=%d matrix_radar=%s median_sweeps=%d",
self._kind,
self._set_name,
len(self._combos),
self._is_matrix_radar,
self._median_sweep_count,
)
self._sweep = self._variants[0].config.radar.sweep
self._radar: MatrixRadarService | SingleRadarService
if self._is_matrix_radar:
if config.is_multi_device:
self._radar = create_matrix_radar_service(config)
self._input_switch = None
self._output_switch = None
self._input_switch = self._output_switch = None
else:
self._radar = create_single_radar_service(config)
self._input_switch = SwitchService.from_model(config.input_switch)
self._output_switch = SwitchService.from_model(config.output_switch)
@property
def kind(self) -> str:
"""Return canonical preprocess asset key for this capture session."""
return self._kind
@property
def set_name(self) -> str:
"""Return destination set name."""
return self._set_name
def open(self) -> None:
"""Open radar and switch resources."""
if self._opened:
return
self._opened = True
try:
self._radar.open()
self._radar.configure(self._config.radar.sweep)
if self._input_switch is not None:
self._input_switch.open()
if self._output_switch is not None:
self._output_switch.open()
logger.info("Sequential capture session opened (kind=%s set=%s)", self._kind, self._set_name)
self._radar.configure(self._sweep)
for switch in (self._input_switch, self._output_switch):
if switch is not None:
switch.open()
except Exception:
logger.exception("Failed to open sequential capture session (kind=%s set=%s)", self._kind, self._set_name)
self.close()
raise
def close(self) -> None:
"""Close all opened hardware resources."""
was_open = self._opened
for resource in (self._output_switch, self._input_switch, self._radar):
if resource is not None:
with suppress(Exception):
resource.close()
self._opened = False
if was_open:
logger.info("Sequential capture session closed (kind=%s set=%s)", self._kind, self._set_name)
def state(self) -> SequentialCaptureState:
"""Return current progress snapshot."""
current_combo = self._current_combo()
complete = self.is_complete()
return SequentialCaptureState(
kind=self._kind,
set_name=self._set_name,
captured_count=len(self._traces),
total_count=len(self._combos),
current_combo=current_combo,
can_undo=bool(self._traces),
is_complete=self.is_complete(),
supports_batch_capture=not self._manual_matrix_radar_capture,
self.kind, self.set_name, len(self._combos) if complete else len(self._batches),
len(self._combos), None if complete else self._combos[len(self._batches)],
bool(self._batches), complete, len(self._variants),
not (self._config.is_multi_device and self.kind == "s21_calibration"),
)
def capture_current_combo(self) -> TraceData:
"""Capture one trace for current combo and advance sequence cursor."""
if not self._opened:
raise RuntimeError("Capture session is not opened")
combo = self._current_combo()
if combo is None:
raise RuntimeError("Capture session is already complete")
if self._is_matrix_radar:
radar = cast(MatrixRadarService, self._radar)
if self._manual_matrix_radar_capture:
# Only this combo is kept, so acquire the smallest collection that
# contains it instead of the full (switch-widened) matrix.
per_sweep_traces = [
select_trace_for_combo(
acquire_matrix_combo_collection(radar, combo), combo
)
for _ in range(self._median_sweep_count)
]
trace = combine_traces_via_median(per_sweep_traces)
self._traces.append(trace)
self._next_index += 1
logger.debug("Captured matrix combo input=%d output=%d", combo.input, combo.output)
return trace
collections: list[SweepCollection] = []
for _ in range(self._median_sweep_count):
collection = radar.acquire_collection(collection_id=1)
if not collection.traces:
raise RuntimeError("Matrix radar capture returned no traces")
collections.append(collection)
combined_collection = combine_collections_via_median(collections)
self._traces.extend(combined_collection.traces)
self._next_index = len(self._combos)
logger.info("Captured full matrix combo set (%d traces)", len(combined_collection.traces))
return combined_collection.traces[-1]
if self._input_switch is None or self._output_switch is None:
raise RuntimeError("Switches are not initialised for combo capture")
self._output_switch.switch_to(combo.output)
self._input_switch.switch_to(combo.input)
if self._config.runtime.settling_ms > 0:
time.sleep(self._config.runtime.settling_ms / 1000.0)
trace = capture_single_radar_combo(cast(SingleRadarService, self._radar), combo, self._median_sweep_count)
self._traces.append(trace)
self._next_index += 1
logger.debug(
"Captured combo input=%d output=%d (%d/%d)",
combo.input,
combo.output,
self._next_index,
len(self._combos),
)
return trace
def undo_last_capture(self) -> TraceData:
"""Remove the most recently captured trace and rewind cursor by one combo."""
if not self._opened:
raise RuntimeError("Capture session is not opened")
if not self._traces or self._next_index <= 0:
raise RuntimeError("No captured combo is available to undo")
if self._is_matrix_radar and not self._manual_matrix_radar_capture:
if len(self._traces) != len(self._combos):
raise RuntimeError("Capture session state is inconsistent; matrix radar trace matrix is incomplete")
removed_trace = self._traces[-1]
self._traces.clear()
self._next_index = 0
logger.info("Undid matrix combo set capture (kind=%s set=%s)", self._kind, self._set_name)
return removed_trace
expected_combo = self._combos[self._next_index - 1]
removed_trace = self._traces[-1]
if (
int(removed_trace.combo.input) != int(expected_combo.input)
or int(removed_trace.combo.output) != int(expected_combo.output)
):
raise RuntimeError("Capture session state is inconsistent; last trace does not match rewind combo")
self._next_index -= 1
self._traces.pop()
logger.debug(
"Undid combo capture input=%d output=%d (%d/%d remaining)",
expected_combo.input,
expected_combo.output,
self._next_index,
len(self._combos),
)
return removed_trace
def last_captured_trace(self) -> TraceData | None:
"""Return the most recently captured trace, if any."""
if not self._traces:
return None
return self._traces[-1]
def captured_traces(self) -> list[TraceData]:
"""Return captured traces in acquisition order."""
return list(self._traces)
def is_complete(self) -> bool:
"""Return `True` when all combos were captured."""
return self._next_index >= len(self._combos)
return bool(self._batches) if self._full_matrix else len(self._batches) == len(self._combos)
def finalize(self, store: NpzStore) -> tuple[str, SweepCollection]:
"""Persist completed capture into store and return radar key + collection."""
def capture_current_combo(self) -> CaptureBatch:
if not self._opened:
raise RuntimeError("Capture session is not opened")
combo = self.state().current_combo
if combo is None:
raise RuntimeError("Capture session is already complete")
if not self._config.is_multi_device:
select_and_settle(self._output_switch, self._input_switch, combo.output, combo.input,
self._config.runtime.settling_ms)
pending: dict[str, list[TraceData]] = {}
for variant in self._variants:
if variant.config.radar.sweep != self._sweep:
self._radar.configure(variant.config.radar.sweep)
self._sweep = variant.config.radar.sweep
if self._config.runtime.settling_ms > 0:
time.sleep(self._config.runtime.settling_ms / 1000.0)
if self._config.is_multi_device:
radar = cast(MatrixRadarService, self._radar)
if self._full_matrix:
collections = [radar.acquire_collection() for _ in range(self._median_sweep_count)]
traces = combine_collections_via_median(collections).traces
if not traces:
raise RuntimeError("Matrix radar capture returned no traces")
else:
traces = [combine_traces_via_median([
select_trace_for_combo(radar.acquire_collection(combo=ComboKey(combo.input, combo.output)), combo)
for _ in range(self._median_sweep_count)])]
else:
traces = [capture_single_radar_combo(cast(SingleRadarService, self._radar),
combo, self._median_sweep_count)]
pending[variant.radar_key] = traces
for key, traces in pending.items():
self._traces[key].extend(traces)
batch = CaptureBatch(combo, tuple(traces[-1] for traces in pending.values()))
self._batches.append(batch)
return batch
def undo_last_capture(self) -> None:
if not self._opened or not self._batches:
raise RuntimeError("No captured combo is available to undo")
for traces in self._traces.values():
del traces[-(len(self._combos) if self._full_matrix else 1):]
self._batches.pop()
def last_captured_trace(self) -> TraceData | None:
return self._batches[-1].display_trace if self._batches else None
def captured_batches(self) -> list[CaptureBatch]:
return list(self._batches)
def finalize(self, store: NpzStore) -> list[SavedCaptureSet]:
if not self.is_complete():
raise RuntimeError("Capture session is not complete")
capture_start_ns, capture_end_ns = trace_capture_window(self._traces)
collection = SweepCollection(
collection_id=1,
monotonic_ns=time.monotonic_ns(),
traces=list(self._traces),
capture_start_ns=capture_start_ns,
capture_end_ns=capture_end_ns,
)
radar_key = radar_key_from_config(
model_name=self._config.radar.model,
serial=self._config.radar.serial,
sweep_start_hz=self._config.radar.sweep.start_hz,
sweep_stop_hz=self._config.radar.sweep.stop_hz,
sweep_points=self._config.radar.sweep.points,
ifbw_hz=self._config.radar.sweep.if_bandwidth_hz,
power_dbm=self._config.radar.sweep.power_dbm,
extra_serials=self._config.radar_key_extra_parts() or None,
)
store.save_set(self._kind, radar_key, self._set_name, collection)
logger.info(
"Finalized capture set kind=%s set=%s radar_key=%s traces=%d",
self._kind,
self._set_name,
radar_key,
len(collection.traces),
)
return radar_key, collection
def _current_combo(self) -> ComboModel | None:
"""Return next combo to capture, or `None` if session is complete."""
if self._next_index >= len(self._combos):
return None
return self._combos[self._next_index]
saved = []
for variant in self._variants:
traces = list(self._traces[variant.radar_key])
start, end = trace_capture_window(traces)
collection = SweepCollection(1, time.monotonic_ns(), traces, start, end)
store.save_set(self.kind, variant.radar_key, self.set_name, collection)
saved.append(SavedCaptureSet(variant.display_name, variant.radar_key, collection))
return saved
def capture_single_radar_combo(
radar: SingleRadarService, combo: ComboModel, median_sweep_count: int,
) -> TraceData:
"""Capture the selected antenna, discarding buffered data from the previous one."""
set_active_combo = getattr(radar, "set_active_combo", None)
if callable(set_active_combo):
set_active_combo(ComboKey(combo.input, combo.output))
# Streaming Kamil sweeps may predate or straddle the switch transition.
drain_after_switch = getattr(radar, "drain_after_switch", None)
if callable(drain_after_switch):
drain_after_switch()
def capture_single_radar_combo(radar: SingleRadarService, combo: ComboModel, count: int) -> TraceData:
radar.prepare_combo(ComboKey(combo.input, combo.output))
traces = []
for _ in range(median_sweep_count):
sweep_start_ns = time.monotonic_ns()
for _ in range(count):
start = time.monotonic_ns()
sweep = radar.acquire()
traces.append(TraceData(
combo=ComboKey(combo.input, combo.output),
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
capture_start_ns=sweep.capture_start_ns or sweep_start_ns,
capture_end_ns=sweep.capture_end_ns or time.monotonic_ns(),
ComboKey(combo.input, combo.output), np.asarray(sweep.x, dtype=np.float32),
np.asarray(sweep.trace("s21"), dtype=np.complex64),
sweep.capture_start_ns or start, sweep.capture_end_ns or time.monotonic_ns(),
))
return combine_traces_via_median(traces)
def acquire_matrix_combo_collection(
radar: MatrixRadarService,
combo: ComboModel,
collection_id: int = 1,
) -> SweepCollection:
"""Acquire the smallest matrix collection that contains one combo.
A switch-widened matrix radar (``SwitchedMatrixRadarService``) can acquire just
the physical switch step carrying the combo, which is several times faster than
the full matrix and keeps the per-combo capture UI responsive. Plain matrix
radars expose only full-matrix acquisition, so they fall back to it.
"""
acquire_combo = getattr(radar, "acquire_combo_collection", None)
if callable(acquire_combo):
collection = cast(SweepCollection, acquire_combo(
input_pos=int(combo.input),
output_pos=int(combo.output),
collection_id=collection_id,
))
else:
collection = radar.acquire_collection(collection_id=collection_id)
if not collection.traces:
raise RuntimeError("Matrix radar capture returned no traces")
return collection
def select_trace_for_combo(collection: SweepCollection, combo: ComboModel) -> TraceData:
"""Return the trace matching a virtual combo from a full multi-device capture."""
for trace in collection.traces:
if (
int(trace.combo.input) == int(combo.input)
and int(trace.combo.output) == int(combo.output)
):
if trace.combo == ComboKey(combo.input, combo.output):
return trace
raise RuntimeError(f"Multi-device capture is missing trace for input={combo.input}, output={combo.output}")
def combine_traces_via_median(traces: list[TraceData]) -> TraceData:
"""Return one trace whose S11/S21 are the per-point median of the inputs.
"""Return one trace whose S21 is the per-point median of the inputs.
A single-element input is returned unchanged. With multiple inputs, the real
and imaginary parts of each complex sample are medianed independently so a
@@ -392,11 +243,7 @@ def combine_traces_via_median(traces: list[TraceData]) -> TraceData:
if not np.array_equal(trace.frequency_hz, first.frequency_hz):
raise RuntimeError(f"Median combine frequency-axis mismatch at sweep {index}")
s11_stack = np.stack([np.asarray(t.s11, dtype=np.complex64) for t in traces], axis=0)
s21_stack = np.stack([np.asarray(t.s21, dtype=np.complex64) for t in traces], axis=0)
s11_median = (
np.median(s11_stack.real, axis=0) + 1j * np.median(s11_stack.imag, axis=0)
).astype(np.complex64)
s21_median = (
np.median(s21_stack.real, axis=0) + 1j * np.median(s21_stack.imag, axis=0)
).astype(np.complex64)
@@ -405,7 +252,6 @@ def combine_traces_via_median(traces: list[TraceData]) -> TraceData:
return TraceData(
combo=ComboKey(input=int(combo.input), output=int(combo.output)),
frequency_hz=np.asarray(first.frequency_hz, dtype=np.float32),
s11=s11_median,
s21=s21_median,
capture_start_ns=capture_start_ns,
capture_end_ns=capture_end_ns,