Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
@@ -17,7 +17,7 @@ from python_app.hardware_full.librevna_multi_device_driver.models import (
normalize_switch_substage_count,
)
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel
if TYPE_CHECKING:
from python_app.hardware_full.librevna_multi_device_driver.controller import MultiDeviceVnaController
@@ -45,34 +45,6 @@ _MAX_RECOVERY_WALL_SECONDS: float = 10.0
# previously caused the periodic freeze.
_MAX_TRANSIENT_COLLECTION_RETRIES: int = 2
def _build_s_parameter_layout(
output_count: int, slave_count: int
) -> tuple[dict[int, tuple[str, ...]], dict[int, str]]:
"""Return the (forward, reflection) trace names for each output position.
Mirrors the naming in ``cycle_collection``: outputs are virtual ports 1..N and
the slaves' receivers are numbered directly above them, so a 2-output setup
keeps the historical ``s31``/``s41``/``s51``/``s61`` layout while a 4-output one
(per-point stimulus switching) shifts the receivers to ports 5..8 to stay
collision-free.
"""
receiver_ports = [
output_count + 2 * slave_index + port_offset
for slave_index in range(slave_count)
for port_offset in (1, 2)
]
forward_by_output = {
output_index: tuple(
f"s{receiver_port}{output_index + 1}" for receiver_port in receiver_ports
)
for output_index in range(output_count)
}
reflection_by_output = {
output_index: f"s{output_index + 1}{output_index + 1}"
for output_index in range(output_count)
}
return forward_by_output, reflection_by_output
@dataclass(slots=True)
class MultiDeviceLibreVnaService:
"""Acquire a virtual 2x4 switch matrix from synchronized LibreVNA devices."""
@@ -81,7 +53,6 @@ class MultiDeviceLibreVnaService:
slave_serials: list[str]
force_external_reference: bool = True
recovery_attempts: int = 3
backend_mode: str = "auto"
# Stimulus switch positions stepped through WITHIN one frequency point. 1 keeps the
# stock 2-output matrix; 2 doubles the outputs (4) without a second sweep, at the
# cost of requiring the modified master bitstream and the trigger-loop controller.
@@ -97,10 +68,6 @@ class MultiDeviceLibreVnaService:
raise ValueError("LibreVNA multi-device mode requires exactly two slave serial numbers")
self.recovery_attempts = max(0, int(self.recovery_attempts))
mode = self.backend_mode.strip().lower()
if mode not in {"auto", "native"}:
raise ValueError(f"Unsupported multi-device backend mode: {self.backend_mode}")
self.backend_mode = mode
self.switch_substage_count = normalize_switch_substage_count(self.switch_substage_count)
@property
@@ -126,7 +93,7 @@ class MultiDeviceLibreVnaService:
self.master_serial, self.slave_serials,
)
except Exception as exc:
logger.warning("Multi-device open failed (backend_mode=%s): %s", self.backend_mode, exc)
logger.warning("Multi-device open failed: %s", exc)
raise
def close(self) -> None:
@@ -234,6 +201,7 @@ class MultiDeviceLibreVnaService:
self,
collection_id: int = 1,
*,
combo: ComboKey | None = None,
stop_event: threading.Event | None = None,
) -> SweepCollection:
"""Acquire one complete virtual 2x4 matrix in canonical combo order.
@@ -244,17 +212,11 @@ class MultiDeviceLibreVnaService:
"""
if self._sweep_configuration is None:
raise RuntimeError("Multi-device service is not configured")
capture_start_ns = time.monotonic_ns()
collection = self._acquire_native_collection_with_recovery(
collection_id, capture_start_ns, stop_event=stop_event
)
collection.capture_end_ns = time.monotonic_ns()
return collection
return self._acquire_native_collection_with_recovery(collection_id, stop_event=stop_event)
def _acquire_native_collection_with_recovery(
self,
collection_id: int,
capture_start_ns: int,
*,
stop_event: threading.Event | None = None,
) -> SweepCollection:
@@ -265,7 +227,7 @@ class MultiDeviceLibreVnaService:
for attempt_index in range(self.recovery_attempts + 1):
try:
return self._acquire_native_collection_with_transient_retries(
collection_id, capture_start_ns, stop_event=stop_event
collection_id, stop_event=stop_event
)
except Exception as exc: # noqa: BLE001
last_error = exc
@@ -311,7 +273,6 @@ class MultiDeviceLibreVnaService:
def _acquire_native_collection_with_transient_retries(
self,
collection_id: int,
capture_start_ns: int,
*,
stop_event: threading.Event | None = None,
) -> SweepCollection:
@@ -328,7 +289,7 @@ class MultiDeviceLibreVnaService:
transient_error: TransientCollectionError | None = None
for transient_attempt in range(_MAX_TRANSIENT_COLLECTION_RETRIES + 1):
try:
return self._acquire_native_collection(collection_id, capture_start_ns)
return self._acquire_native_collection(collection_id)
except TransientCollectionError as exc:
transient_error = exc
if transient_attempt >= _MAX_TRANSIENT_COLLECTION_RETRIES:
@@ -345,62 +306,32 @@ class MultiDeviceLibreVnaService:
assert transient_error is not None # loop either returns or raises
raise transient_error
def _acquire_native_collection(self, collection_id: int, capture_start_ns: int) -> SweepCollection:
def _acquire_native_collection(self, collection_id: int) -> SweepCollection:
if self._controller is None:
raise RuntimeError("Multi-device controller is not open")
assert self._sweep_configuration is not None
self._controller.configure_continuous_sweep(self._sweep_configuration)
# Bound the sweep itself rather than reusing `capture_start_ns`: the latter
# is taken before any retry/recovery, so it would overstate how long the
# traces below took to measure.
sweep_start_ns = time.monotonic_ns()
result = self._controller.collect_running_sweep_cycles(
1,
result = self._controller.collect_running_sweep_cycle(
datapoint_timeout_seconds=LIBREVNA_NATIVE_SWEEP_TIMEOUT_SECONDS,
)
sweep_end_ns = time.monotonic_ns()
normalized_s_parameters = {
str(name).lower(): np.asarray(values, dtype=np.complex64)
for name, values in result.s_parameters.items()
}
frequencies = np.asarray(result.frequencies_hz, dtype=np.float32)
forward_by_output, reflection_by_output = _build_s_parameter_layout(
self.output_position_count, len(self.slave_serials)
)
traces: list[TraceData] = []
for output_pos in range(self.output_position_count):
reflection = self._required_s_parameter(
normalized_s_parameters,
reflection_by_output[output_pos],
traces = [
TraceData(
combo=ComboKey(input_pos, output_pos),
frequency_hz=frequencies,
s21=np.asarray(result.s_parameters[input_pos, output_pos], dtype=np.complex64),
capture_start_ns=result.capture_start_ns,
capture_end_ns=result.capture_end_ns,
)
for input_pos, s_parameter_name in enumerate(forward_by_output[output_pos]):
traces.append(
TraceData(
combo=ComboKey(input=input_pos, output=output_pos),
frequency_hz=frequencies,
s11=reflection,
s21=self._required_s_parameter(normalized_s_parameters, s_parameter_name),
# Every combo comes out of the same synchronized cycle, so
# they all share one window — no combo was measured earlier
# or later than another here.
capture_start_ns=sweep_start_ns,
capture_end_ns=sweep_end_ns,
)
)
for output_pos in range(self.output_position_count)
for input_pos in range(2 * len(self.slave_serials))
]
return SweepCollection(
collection_id=int(collection_id),
monotonic_ns=time.monotonic_ns(),
traces=traces,
capture_start_ns=capture_start_ns,
capture_start_ns=result.capture_start_ns,
capture_end_ns=result.capture_end_ns,
)
@staticmethod
def _required_s_parameter(s_parameters: dict[str, np.ndarray], name: str) -> np.ndarray:
values = s_parameters.get(name)
if values is None:
raise RuntimeError(f"Multi-device sweep is missing required {name.upper()} trace")
return values