Files
radar_system/python_app/workflows/kamil_adc_neutral_preprocess.py
T
2026-10-05 16:46:28 +03:00

122 lines
4.1 KiB
Python

"""Neutral preprocessing-set helpers — the "run without calibration" path.
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.
"""
from __future__ import annotations
import logging
import time
import numpy as np
from python_app.hardware_full.kamil_adc import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import ComboModel, RunConfigModel
logger = logging.getLogger(__name__)
def supports_neutral_preprocess_sets(config: RunConfigModel) -> bool:
"""Enable the shortcut only when the emitted frequency grid is verified."""
return bool(config.is_kamil_adc or config.is_multi_device or (
config.radar.model == RunConfigModel.LIBREVNA_MODEL and config.radar.driver_mode == "mock"
))
def neutral_frequency_grid_hz(config: RunConfigModel) -> np.ndarray:
"""Derive the neutral axis; the preprocessor verifies it against measured data."""
if config.is_kamil_adc:
# Single source of truth for the axis: the same grid the processor emits.
processor = KamilAdcSweepProcessor(
KamilAdcProcessingParams.from_kamil_model(config.radar.kamil_adc)
)
return processor.grid_hz
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.
return np.linspace(
float(config.radar.sweep.start_hz),
float(config.radar.sweep.stop_hz),
points,
dtype=np.float32,
)
def build_neutral_s21_sets(
config: RunConfigModel,
) -> tuple[SweepCollection, SweepCollection]:
"""Build neutral S21 calibration/reference collections for the active radar.
Covers every combo in the effective matrix, so a matrix radar widened by real
switches gets a neutral pair for all of its positions and the preprocessor's
``validate_combos()`` is satisfied. Returns ``(calibration, reference)``.
"""
combos = list(config.combos)
if not combos:
combos = config.build_runtime_combos()
if not combos:
raise ValueError("Neutral sets require at least one switch combo")
frequency_hz = neutral_frequency_grid_hz(config)
now_ns = time.monotonic_ns()
calibration = _neutral_collection(
combos=combos,
frequency_hz=frequency_hz,
s21_value=np.complex64(1.0 + 0.0j),
monotonic_ns=now_ns,
)
reference = _neutral_collection(
combos=combos,
frequency_hz=frequency_hz,
s21_value=np.complex64(0.0 + 0.0j),
monotonic_ns=now_ns,
)
logger.info(
"Built neutral S21 sets: model=%s combos=%d points=%d",
config.radar.model,
len(combos),
int(frequency_hz.size),
)
return calibration, reference
def _neutral_collection(
*,
combos: list[ComboModel],
frequency_hz: np.ndarray,
s21_value: np.complex64,
monotonic_ns: int,
) -> SweepCollection:
point_count = int(frequency_hz.size)
traces = [
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
]
return SweepCollection(
collection_id=1,
monotonic_ns=monotonic_ns,
traces=traces,
capture_start_ns=monotonic_ns,
capture_end_ns=monotonic_ns,
)