multi config calibration added
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
"""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 time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.librevna_service import LibreVnaService
|
||||
from python_app.hardware_full.switch_service import SwitchService
|
||||
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
|
||||
from python_app.workflows.radar_config_variants import RadarConfigVariant
|
||||
from python_app.workflows.sequential_capture_workflow import SequentialCaptureState
|
||||
|
||||
|
||||
@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],
|
||||
) -> 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")
|
||||
|
||||
self._base_config = base_config
|
||||
self._kind = kind
|
||||
self._set_name = set_name
|
||||
self._radar_variants = list(radar_variants)
|
||||
self._combos = RunConfigModel.build_full_combos(
|
||||
base_config.input_switch.positions,
|
||||
base_config.output_switch.positions,
|
||||
)
|
||||
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
|
||||
|
||||
self._radar = LibreVnaService(serial=base_config.radar.serial or None)
|
||||
self._input_switch = SwitchService(
|
||||
name=base_config.input_switch.name,
|
||||
positions=base_config.input_switch.positions,
|
||||
mode=base_config.input_switch.driver_mode,
|
||||
driver=base_config.input_switch.driver,
|
||||
gpio_chip=base_config.input_switch.gpio_chip,
|
||||
pin_a=base_config.input_switch.pin_a,
|
||||
pin_b=base_config.input_switch.pin_b,
|
||||
invert_logic=base_config.input_switch.invert_logic,
|
||||
)
|
||||
self._output_switch = SwitchService(
|
||||
name=base_config.output_switch.name,
|
||||
positions=base_config.output_switch.positions,
|
||||
mode=base_config.output_switch.driver_mode,
|
||||
driver=base_config.output_switch.driver,
|
||||
gpio_chip=base_config.output_switch.gpio_chip,
|
||||
pin_a=base_config.output_switch.pin_a,
|
||||
pin_b=base_config.output_switch.pin_b,
|
||||
invert_logic=base_config.output_switch.invert_logic,
|
||||
)
|
||||
|
||||
@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)
|
||||
self._input_switch.open()
|
||||
self._output_switch.open()
|
||||
except Exception:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close all opened hardware resources."""
|
||||
with suppress(Exception):
|
||||
self._output_switch.close()
|
||||
with suppress(Exception):
|
||||
self._input_switch.close()
|
||||
with suppress(Exception):
|
||||
self._radar.close()
|
||||
self._opened = False
|
||||
|
||||
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=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),
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
traces: list[TraceData] = []
|
||||
variant_labels: list[str] = []
|
||||
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)
|
||||
sweep = self._radar.acquire()
|
||||
trace = TraceData(
|
||||
combo=ComboKey(input_pos=combo.input, output_pos=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),
|
||||
)
|
||||
traces.append(trace)
|
||||
variant_labels.append(variant.display_name)
|
||||
self._traces_by_radar_key[variant.radar_key].append(trace)
|
||||
|
||||
batch = MultiRadarCaptureBatch(
|
||||
combo=combo,
|
||||
traces=tuple(traces),
|
||||
variant_labels=tuple(variant_labels),
|
||||
)
|
||||
self._captured_batches.append(batch)
|
||||
self._next_index += 1
|
||||
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")
|
||||
|
||||
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 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])
|
||||
collection = SweepCollection(
|
||||
collection_id=1,
|
||||
monotonic_ns=monotonic_ns,
|
||||
traces=traces,
|
||||
)
|
||||
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),
|
||||
)
|
||||
)
|
||||
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]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Helpers for loading radar sweep variants from a directory of JSON files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.storage.npz_store import radar_key_from_config
|
||||
|
||||
_RADAR_SWEEP_KEYS = (
|
||||
"start_hz",
|
||||
"stop_hz",
|
||||
"points",
|
||||
"if_bandwidth_hz",
|
||||
"stimulus_power_dbm",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RadarConfigVariant:
|
||||
"""One capture-ready radar sweep variant loaded from JSON."""
|
||||
|
||||
source_path: Path
|
||||
display_name: str
|
||||
config: RunConfigModel
|
||||
radar_key: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RadarConfigScanSummary:
|
||||
"""Scan results for one radar-config directory refresh."""
|
||||
|
||||
directory_path: str
|
||||
json_file_count: int
|
||||
valid_variant_count: int
|
||||
skipped_file_count: int
|
||||
duplicate_variant_count: int
|
||||
issues: tuple[str, ...]
|
||||
|
||||
|
||||
def scan_radar_config_variants(
|
||||
directory_path: str,
|
||||
*,
|
||||
base_config: RunConfigModel,
|
||||
) -> tuple[list[RadarConfigVariant], RadarConfigScanSummary]:
|
||||
"""Load valid radar sweep variants from `directory_path` using `base_config` as the baseline."""
|
||||
normalized_path = str(directory_path).strip()
|
||||
if not normalized_path:
|
||||
return [], RadarConfigScanSummary(
|
||||
directory_path="",
|
||||
json_file_count=0,
|
||||
valid_variant_count=0,
|
||||
skipped_file_count=0,
|
||||
duplicate_variant_count=0,
|
||||
issues=(),
|
||||
)
|
||||
|
||||
directory = Path(normalized_path).expanduser()
|
||||
if not directory.exists():
|
||||
return [], RadarConfigScanSummary(
|
||||
directory_path=str(directory),
|
||||
json_file_count=0,
|
||||
valid_variant_count=0,
|
||||
skipped_file_count=0,
|
||||
duplicate_variant_count=0,
|
||||
issues=(f"Directory does not exist: {directory}",),
|
||||
)
|
||||
if not directory.is_dir():
|
||||
return [], RadarConfigScanSummary(
|
||||
directory_path=str(directory),
|
||||
json_file_count=0,
|
||||
valid_variant_count=0,
|
||||
skipped_file_count=0,
|
||||
duplicate_variant_count=0,
|
||||
issues=(f"Path is not a directory: {directory}",),
|
||||
)
|
||||
|
||||
variants: list[RadarConfigVariant] = []
|
||||
issues: list[str] = []
|
||||
duplicate_variant_count = 0
|
||||
seen_radar_keys: set[str] = set()
|
||||
json_paths = sorted(path for path in directory.glob("*.json") if path.is_file())
|
||||
|
||||
for path in json_paths:
|
||||
try:
|
||||
variant = _load_radar_config_variant(path, base_config=base_config)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
issues.append(f"{path.name}: {type(exc).__name__}: {exc}")
|
||||
continue
|
||||
if variant.radar_key in seen_radar_keys:
|
||||
duplicate_variant_count += 1
|
||||
issues.append(
|
||||
f"{path.name}: duplicate radar variant key {variant.radar_key}; keeping the first matching file only"
|
||||
)
|
||||
continue
|
||||
seen_radar_keys.add(variant.radar_key)
|
||||
variants.append(variant)
|
||||
|
||||
return variants, RadarConfigScanSummary(
|
||||
directory_path=str(directory),
|
||||
json_file_count=len(json_paths),
|
||||
valid_variant_count=len(variants),
|
||||
skipped_file_count=max(0, len(json_paths) - len(variants) - duplicate_variant_count),
|
||||
duplicate_variant_count=duplicate_variant_count,
|
||||
issues=tuple(issues),
|
||||
)
|
||||
|
||||
|
||||
def _load_radar_config_variant(path: Path, *, base_config: RunConfigModel) -> RadarConfigVariant:
|
||||
"""Load one radar sweep variant by overlaying JSON `radar.sweep` onto `base_config`."""
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("JSON root must be an object")
|
||||
|
||||
radar_payload = payload.get("radar")
|
||||
if not isinstance(radar_payload, dict):
|
||||
raise ValueError("Missing radar section")
|
||||
|
||||
sweep_payload = radar_payload.get("sweep")
|
||||
if not isinstance(sweep_payload, dict):
|
||||
raise ValueError("Missing radar.sweep section")
|
||||
if not any(key in sweep_payload for key in _RADAR_SWEEP_KEYS):
|
||||
raise ValueError("radar.sweep does not contain any supported sweep keys")
|
||||
|
||||
config = base_config.clone()
|
||||
sweep = config.radar.sweep
|
||||
if "start_hz" in sweep_payload:
|
||||
sweep.start_hz = float(sweep_payload["start_hz"])
|
||||
if "stop_hz" in sweep_payload:
|
||||
sweep.stop_hz = float(sweep_payload["stop_hz"])
|
||||
if "points" in sweep_payload:
|
||||
sweep.points = int(sweep_payload["points"])
|
||||
if "if_bandwidth_hz" in sweep_payload:
|
||||
sweep.if_bandwidth_hz = float(sweep_payload["if_bandwidth_hz"])
|
||||
if "stimulus_power_dbm" in sweep_payload:
|
||||
sweep.power_dbm = float(sweep_payload["stimulus_power_dbm"])
|
||||
|
||||
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,
|
||||
)
|
||||
return RadarConfigVariant(
|
||||
source_path=path,
|
||||
display_name=path.stem,
|
||||
config=config,
|
||||
radar_key=radar_key,
|
||||
)
|
||||
@@ -26,6 +26,7 @@ class SequentialCaptureState:
|
||||
current_combo: ComboModel | None
|
||||
can_undo: bool
|
||||
is_complete: bool
|
||||
variant_count: int = 1
|
||||
|
||||
|
||||
class SequentialCaptureSession:
|
||||
|
||||
Reference in New Issue
Block a user