Rework radar system
This commit is contained in:
@@ -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]
|
||||
+6
-13
@@ -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}")
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user