"""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. Supported models: Kamil ADC (axis from the ADC processing grid) and every VNA-style model, including synchronized multi-device LibreVNA (axis from the configured linear sweep grid). """ 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: """Return whether neutral S21 sets can be generated for this radar model. Enabled for the Kamil ADC and for synchronized multi-device LibreVNA, the two models whose emitted frequency axis is fully derivable from the config alone. Other models still work through `build_neutral_s21_sets`, but are kept out of the UI shortcut until their axis has been verified against real hardware. """ return bool(config.is_kamil_adc or config.is_multi_device) def neutral_frequency_grid_hz(config: RunConfigModel) -> np.ndarray: """Return the exact per-trace frequency axis the configured radar emits. Neutral sets must line up sample-for-sample with live sweeps, so the axis comes from the same source the acquisition path uses: the ADC processing grid for Kamil ADC, and the configured linear sweep grid for every VNA-style model (LibreVNA single and multi-device, SN9000, Compact-M). The C++ preprocessor re-checks this axis against the measured one within a tolerance, so a mismatch fails loudly instead of silently corrupting the correction. """ 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") if points == 1: return np.array([float(config.radar.sweep.start_hz)], dtype=np.float32) # 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 build_kamil_adc_neutral_s21_sets( config: RunConfigModel, ) -> tuple[SweepCollection, SweepCollection]: """Build neutral S21 sets, rejecting anything but the Kamil ADC radar. Kept as the model-checked entry point for the ADC path; new callers that must work for several radar models should use `build_neutral_s21_sets` instead. """ if not config.is_kamil_adc: raise ValueError("Neutral Kamil ADC sets require radar.model='kamil_adc'") return build_neutral_s21_sets(config) 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, )