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
+1 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import logging
from python_app.models.run_config_model import RunConfigModel
from python_app.models.run_config_schema import RunConfigModel
logger = logging.getLogger(__name__)
@@ -44,6 +44,7 @@ from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from numpy.typing import NDArray
from python_app.models.run_config_schema import KamilAdcModel
@@ -189,7 +190,7 @@ class KamilAdcSweepProcessor:
return phase + shift, anchor + shift
return phase, anchor
def process(self, main: np.ndarray, reference: np.ndarray) -> np.ndarray | None:
def process(self, main: np.ndarray, reference: np.ndarray) -> NDArray[np.complex64] | None:
"""Return the S21 trace resampled onto the fixed grid, or ``None`` to reject.
``main`` and ``reference`` are equal-length complex arrays ordered by
@@ -24,6 +24,7 @@ from dataclasses import dataclass
import struct
import numpy as np
from numpy.typing import NDArray
# Wire-format constants.
FRAME_BYTES = 8
@@ -55,9 +56,9 @@ class RawSweep:
samples at those steps. All three arrays share the same length.
"""
steps: np.ndarray
main: np.ndarray
reference: np.ndarray
steps: NDArray[np.int32]
main: NDArray[np.complex64]
reference: NDArray[np.complex64]
combo: tuple[int, int] | None = None
dirty: bool = False
@@ -30,7 +30,8 @@ from python_app.hardware_full.kamil_adc.tty_reader import (
raise_if_process_exited,
)
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
from python_app.models.dataset_model import ComboKey
from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel
logger = logging.getLogger(__name__)
@@ -178,12 +179,15 @@ class KamilAdcService:
return SweepResult(
x=grid.copy(),
traces={
"s11": np.zeros(points, dtype=np.complex64),
"s21": s21,
},
)
self._log_rejected_sweep(raw)
def prepare_combo(self, combo: ComboKey) -> None:
"""Discard buffered sweeps that predate or straddle the physical switch."""
self.drain_after_switch()
def drain_after_switch(self, sweeps: int = _SWITCH_DRAIN_SWEEPS) -> None:
"""Discard sweeps captured before / across a just-applied switch change.
@@ -362,9 +366,10 @@ class KamilAdcService:
self._rejected_count += 1
if self._rejected_count != 1 and self._rejected_count % _REJECT_LOG_EVERY != 0:
return
params = self._processor.params # type: ignore[union-attr]
assert self._processor is not None
params = self._processor.params
try:
freqs = self._processor.reference_frequency_axis(raw.reference) # type: ignore[union-attr]
freqs = self._processor.reference_frequency_axis(raw.reference)
covered = f"[{float(np.min(freqs)):.6g}, {float(np.max(freqs)):.6g}]"
except Exception: # noqa: BLE001 — diagnostics must never raise
covered = "<unavailable>"
@@ -14,13 +14,11 @@ SEND_PARAMS_TOTAL_LENGTH = 30 # bytes in DECODE_ENABLE command
TASK_ENABLE_COMMAND_LENGTH = 32 # bytes in TASK_ENABLE command
WAIT_AFTER_SEND_SEC = 0.15 # delay after sending a command
GUI_POLL_INTERVAL_MS = 5 # GUI event loop timeout
# ---- Command codes (as sent to device, already flipped to LE)
CMD_DECODE_ENABLE = 0x1111 # Set control parameters
CMD_DEFAULT_ENABLE = 0x2222 # Reset device
CMD_TRANSS_ENABLE = 0x3333 # Request all saved data (not implemented)
CMD_TRANS_ENABLE = 0x4444 # Request last data
CMD_REMOVE_FILE = 0x5555 # Delete saved data
CMD_STATE = 0x6666 # Request state
@@ -54,7 +52,6 @@ VREF = 2.5 # Reference voltage, Volts
# Bridge resistors for temperature measurement
R1 = 10000 # Ohm
R2 = 2200 # Ohm
R3 = 27000 # Ohm
R4 = 30000 # Ohm
R5 = 27000 # Ohm
@@ -105,17 +102,3 @@ TIME_STEP_MAX_US = 100 # Maximum time step, microseconds
DELAY_TIME_MIN_MS = 3 # Minimum delay between pulses, milliseconds
DELAY_TIME_MAX_MS = 10 # Maximum delay between pulses, milliseconds
# ---- Acceptable voltage tolerances for power rail health check
VOLT_3V3_MIN = 3.1
VOLT_3V3_MAX = 3.5
VOLT_5V_MIN = 4.8
VOLT_5V_MAX = 5.3
VOLT_7V_MIN = 6.5
VOLT_7V_MAX = 7.5
# ---- Data buffer limits
MAX_DATA_POINTS = 1000 # Max stored measurement points
PLOT_POINTS = 100 # Points shown in real-time plots
@@ -13,18 +13,12 @@ from typing import Optional, Callable
from .protocol import Protocol, TaskType as ProtoTaskType
from .validators import ParameterValidator
from .models import (
ManualModeParams,
VariationParams,
VariationType,
Measurements,
DeviceStatus,
DeviceState,
)
from .exceptions import (
ValidationError,
CommunicationError,
DeviceNotRespondingError,
DeviceStateError,
)
from .constants import WAIT_AFTER_SEND_SEC
@@ -301,38 +295,6 @@ class LaserController:
return measurements
def get_status(self) -> DeviceStatus:
"""
Request and return the current device status.
Returns:
:class:`DeviceStatus` with state and latest measurements.
Raises:
CommunicationError: On transport errors.
"""
cmd = Protocol.encode_state()
self._send(cmd)
raw = self._protocol.receive_raw(2)
if not raw or len(raw) < 2:
raise DeviceNotRespondingError()
state_code = Protocol.decode_state(raw)
# Try to get measurements as well
measurements = self._last_measurements
return DeviceStatus(
state=DeviceState(state_code) if state_code in DeviceState._value2member_map_
else DeviceState.ERROR,
measurements=measurements,
is_connected=self.is_connected,
last_command_id=self._message_id,
error_message=Protocol.state_to_description(f"{state_code:04x}")
if state_code != 0 else None,
)
def reset(self) -> None:
"""Send a hardware reset command to the device."""
cmd = Protocol.encode_default_enable()
@@ -60,7 +60,7 @@ class CommunicationError(LaserControlError):
class PortNotFoundError(CommunicationError):
"""Exception raised when serial port cannot be found."""
def __init__(self, port: str = None):
def __init__(self, port: str | None = None):
if port:
message = f"Serial port '{port}' not found"
else:
@@ -77,7 +77,7 @@ class PortBusyError(CommunicationError):
of silently sharing the bus and corrupting both sides' traffic.
"""
def __init__(self, port: str = None):
def __init__(self, port: str | None = None):
target = f"'{port}'" if port else "the laser controller port"
super().__init__(
f"Serial port {target} is busy — another program is already using "
@@ -89,7 +89,7 @@ class PortBusyError(CommunicationError):
class DeviceNotRespondingError(CommunicationError):
"""Exception raised when device doesn't respond to commands."""
def __init__(self, timeout: float = None):
def __init__(self, timeout: float | None = None):
if timeout:
message = f"Device did not respond within {timeout} seconds"
else:
@@ -100,7 +100,7 @@ class DeviceNotRespondingError(CommunicationError):
class CRCError(CommunicationError):
"""Exception raised when CRC check fails."""
def __init__(self, expected: int = None, received: int = None):
def __init__(self, expected: int | None = None, received: int | None = None):
if expected is not None and received is not None:
message = f"CRC check failed. Expected: 0x{expected:04X}, Received: 0x{received:04X}"
else:
@@ -113,45 +113,3 @@ class ProtocolError(CommunicationError):
def __init__(self, message: str):
super().__init__(f"Protocol error: {message}")
class DeviceError(LaserControlError):
"""Base exception for device-level errors."""
pass
class DeviceOverheatingError(DeviceError):
"""Exception raised when device reports overheating."""
def __init__(self, laser_id: int = None, temperature: float = None):
if laser_id and temperature:
message = f"Laser {laser_id} overheating: {temperature}°C"
else:
message = "Device overheating detected"
super().__init__(message)
class PowerSupplyError(DeviceError):
"""Exception raised when power supply issues are detected."""
def __init__(self, rail: str = None, voltage: float = None, expected: float = None):
if rail and voltage is not None:
if expected:
message = f"Power supply {rail}: {voltage}V (expected ~{expected}V)"
else:
message = f"Power supply {rail}: abnormal voltage {voltage}V"
else:
message = "Power supply error detected"
super().__init__(message)
class DeviceStateError(DeviceError):
"""Exception raised when device is in an error state."""
def __init__(self, state_code: int, state_name: str = None):
self.state_code = state_code
if state_name:
message = f"Device error state: {state_name} (0x{state_code:04X})"
else:
message = f"Device error state: 0x{state_code:04X}"
super().__init__(message)
@@ -20,77 +20,6 @@ class VariationType(IntEnum):
CHANGE_TEMPERATURE_LD2 = 0x04
class DeviceState(IntEnum):
"""Device operational states."""
IDLE = 0x0000
RUNNING = 0x0001
BUSY = 0x0002
ERROR = 0x00FF
ERROR_OVERHEAT = 0x0100
ERROR_POWER = 0x0200
ERROR_COMMUNICATION = 0x0400
ERROR_INVALID_COMMAND = 0x0800
@dataclass
class ManualModeParams:
"""Parameters for manual control mode."""
temp1: float # Temperature for laser 1 (°C)
temp2: float # Temperature for laser 2 (°C)
current1: float # Current for laser 1 (mA)
current2: float # Current for laser 2 (mA)
pi_coeff1_p: float = 1.0 # PI controller proportional coefficient for laser 1
pi_coeff1_i: float = 0.5 # PI controller integral coefficient for laser 1
pi_coeff2_p: float = 1.0 # PI controller proportional coefficient for laser 2
pi_coeff2_i: float = 0.5 # PI controller integral coefficient for laser 2
def to_dict(self) -> Dict[str, float]:
"""Convert to dictionary representation."""
return {
'temp1': self.temp1,
'temp2': self.temp2,
'current1': self.current1,
'current2': self.current2,
'pi_coeff1_p': self.pi_coeff1_p,
'pi_coeff1_i': self.pi_coeff1_i,
'pi_coeff2_p': self.pi_coeff2_p,
'pi_coeff2_i': self.pi_coeff2_i
}
@dataclass
class VariationParams:
"""Parameters for variation mode."""
variation_type: VariationType
# Static parameters (fixed during variation)
static_temp1: float
static_temp2: float
static_current1: float
static_current2: float
# Variation range
min_value: float # Minimum value for varied parameter
max_value: float # Maximum value for varied parameter
step: float # Step size for variation
# Time parameters
time_step: int # Time step in microseconds (20-100)
delay_time: int # Delay between measurements in milliseconds (3-10)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary representation."""
return {
'variation_type': self.variation_type.value,
'static_temp1': self.static_temp1,
'static_temp2': self.static_temp2,
'static_current1': self.static_current1,
'static_current2': self.static_current2,
'min_value': self.min_value,
'max_value': self.max_value,
'step': self.step,
'time_step': self.time_step,
'delay_time': self.delay_time
}
@dataclass
class Measurements:
"""Real-time measurements from the device."""
@@ -134,86 +63,3 @@ class Measurements:
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
'message_id': self.message_id
}
def check_power_rails(self) -> Dict[str, bool]:
"""Check if power supply voltages are within acceptable range."""
return {
'3v3': 3.1 <= self.voltage_3v3 <= 3.5,
'5v1': 4.8 <= self.voltage_5v1 <= 5.3,
'5v2': 4.8 <= self.voltage_5v2 <= 5.3,
'7v0': 6.5 <= self.voltage_7v0 <= 7.5
}
@dataclass
class DeviceStatus:
"""Complete device status information."""
state: DeviceState
measurements: Optional[Measurements] = None
is_connected: bool = False
last_command_id: Optional[int] = None
error_message: Optional[str] = None
@property
def is_idle(self) -> bool:
"""Check if device is idle."""
return self.state == DeviceState.IDLE
@property
def is_running(self) -> bool:
"""Check if device is running a task."""
return self.state == DeviceState.RUNNING
@property
def has_error(self) -> bool:
"""Check if device has any error."""
return self.state >= DeviceState.ERROR
@property
def error_type(self) -> Optional[str]:
"""Get human-readable error type."""
if not self.has_error:
return None
error_map = {
DeviceState.ERROR_OVERHEAT: "Overheating",
DeviceState.ERROR_POWER: "Power supply issue",
DeviceState.ERROR_COMMUNICATION: "Communication error",
DeviceState.ERROR_INVALID_COMMAND: "Invalid command"
}
return error_map.get(self.state, "Unknown error")
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary representation."""
return {
'state': self.state.value,
'state_name': self.state.name,
'measurements': self.measurements.to_dict() if self.measurements else None,
'is_connected': self.is_connected,
'last_command_id': self.last_command_id,
'error_message': self.error_message,
'is_idle': self.is_idle,
'is_running': self.is_running,
'has_error': self.has_error,
'error_type': self.error_type
}
@dataclass
class CalibrationData:
"""Calibration data for device sensors."""
# Temperature calibration coefficients
temp1_offset: float = 0.0
temp1_scale: float = 1.0
temp2_offset: float = 0.0
temp2_scale: float = 1.0
# Current calibration coefficients
current1_offset: float = 0.0
current1_scale: float = 1.0
current2_offset: float = 0.0
current2_scale: float = 1.0
# Voltage calibration
voltage_3v3_scale: float = 1.0
voltage_5v1_scale: float = 1.0
voltage_5v2_scale: float = 1.0
voltage_7v0_scale: float = 1.0
@@ -21,7 +21,7 @@
## Архитектура
```
[starter] ── TASK_ENABLE ──► плата кратко открыл порт, послал, закрыл
[starter] ── TASK_ENABLE ──► плата запускает вариацию
│ пишет session.json (target temp1/2, tolerance, variation_type)
▼
[monitor] ── TRANS_ENABLE ──► плата владеет портом всё время работы
@@ -32,19 +32,21 @@
сверяет temp1↔target_temp1 и temp2↔target_temp2, |Δ|>tol ─► WARNING в консоль
```
- **Порт лазера эксклюзивен.** `starter` трогает его кратко, затем `monitor`
владеет им всё время. `checker` порт не трогает вовсе — читает только файлы.
- **Порт лазера эксклюзивен.** При `--start` запуск и мониторинг используют одно
соединение. Штатный пайплайн закрывает порт после запуска вариации, и его
может открыть отдельный монитор. `checker` читает только файлы.
- **Связь через файлы** (JSONL + JSON), а не сокеты, — процессы стартуют,
останавливаются и перезапускаются независимо, без рукопожатия.
- **Сверяются оба лазера** по внутренним `temp1`/`temp2` (не по внешним
термисторам `temp_ext*`), каждый со своим допуском (по умолчанию `0.03 °C`).
термисторам `temp_ext*`), с общим допуском (по умолчанию `0.03 °C`).
## Быстрый старт (CLI, два процесса)
Терминал 1 — стартовать вариацию и мониторить температуру:
Команды запускаются из корня проекта. Терминал 1 — стартовать вариацию и
мониторить температуру:
```bash
python -m python_app.scripts.laser_temp_monitor \
.venv/bin/python -m python_app.scripts.laser_temp_monitor \
--config run_config.json \
--start
```
@@ -52,15 +54,15 @@ python -m python_app.scripts.laser_temp_monitor \
Терминал 2 — валидировать температуру и печатать предупреждения:
```bash
python -m python_app.scripts.laser_temp_checker
.venv/bin/python -m python_app.scripts.laser_temp_checker
```
Пример вывода чекера при расхождении и возврате в допуск:
```
WARNING laser_temp_checker: Laser 1 temperature off target: measured 28.100 °C,
WARNING python_app.hardware_full.laser_control.monitoring.checker: Laser 1 temperature off target: measured 28.100 °C,
target 28.000 °C, Δ=+0.100 °C exceeds tolerance ±0.030 °C [seq=1]
INFO laser_temp_checker: Laser 1 temperature back within tolerance:
INFO python_app.hardware_full.laser_control.monitoring.checker: Laser 1 temperature back within tolerance:
28.000 °C (target 28.000, |Δ|=0.000 ≤ 0.030) [seq=2]
```
@@ -69,7 +71,7 @@ INFO laser_temp_checker: Laser 1 temperature back within tolerance:
## Конфигурация
Параметры берутся из `run_config.json`, секция `radar.laser_control`. Мониторинг
использует блок `variation` и новое поле `temp_tolerance_c`:
использует блок `variation`, включая `temp_tolerance_c`:
```json
{
@@ -105,7 +107,7 @@ INFO laser_temp_checker: Laser 1 temperature back within tolerance:
| Поле | Смысл |
|---|---|
| `port` | Серийный порт лазерной платы (пусто → автоопределение) |
| `port` | Серийный порт лазерной платы; CLI-монитор допускает пустое значение для автоопределения, пайплайн требует явный порт |
| `static_temp1` / `static_temp2` | Целевые статичные температуры лазеров 1/2, °C |
| `min_value` / `max_value` / `step` | Диапазон и шаг свипа тока, мА — из них считается период свипа |
| `time_step` / `delay_time` | Тайминги точки (мкс / мс) — тоже входят в период свипа |
@@ -126,13 +128,13 @@ INFO laser_temp_checker: Laser 1 temperature back within tolerance:
Мониторить уже запущенную из GUI/пайплайна вариацию (без повторного старта):
```bash
python -m python_app.scripts.laser_temp_monitor --config run_config.json
.venv/bin/python -m python_app.scripts.laser_temp_monitor --config run_config.json
```
Фиксированный период вместо расчётного (напр. раз в 500 мс):
```bash
python -m python_app.scripts.laser_temp_monitor \
.venv/bin/python -m python_app.scripts.laser_temp_monitor \
--config run_config.json --strategy interval:500
```
@@ -150,10 +152,10 @@ python -m python_app.scripts.laser_temp_monitor \
```bash
# монитор
python -m python_app.scripts.laser_temp_monitor --config cfg.json --start \
.venv/bin/python -m python_app.scripts.laser_temp_monitor --config cfg.json --start \
--readings /tmp/run7.jsonl --session /tmp/run7.session.json
# чекер
python -m python_app.scripts.laser_temp_checker \
.venv/bin/python -m python_app.scripts.laser_temp_checker \
--readings /tmp/run7.jsonl --session /tmp/run7.session.json --reminder-every 20
```
@@ -161,9 +163,10 @@ python -m python_app.scripts.laser_temp_checker \
Когда вариацию стартует штатный пайплайн
([`apply_kamil_adc_laser_control`](../../kamil_adc/laser.py)), снимок сессии
`session.json` пишется автоматически. Достаточно запустить только чекер
(и, при желании, монитор без `--start`, чтобы он опрашивал плату). Так консоль
получит предупреждения о рассинхроне температуры прямо во время захвата.
`<tmp>/laser_variation_session.json` пишется автоматически. Для предупреждений
во время захвата нужны монитор без `--start`, который создаёт показания, и чекер.
Если монитор уже работает, достаточно добавить чекер. Он загружает сессию
один раз при запуске; после запуска новой вариации чекер нужно перезапустить.
## Встраивание в свой код (без CLI)
@@ -187,15 +190,22 @@ period = resolve_period_s("computed", min_value=33.0, max_value=60.0, step=0.05,
stop = threading.Event()
with LaserController(port="/dev/ttyUSB0") as ctrl, ReadingWriter("readings.jsonl") as w:
monitor = LaserTemperatureMonitor(ctrl, w, period_s=period)
threading.Thread(target=monitor.run, args=(stop,), daemon=True).start()
worker = threading.Thread(target=monitor.run, args=(stop,))
worker.start()
# 3. Чекер: тайлить показания и валидировать оба лазера
checker = LaserTemperatureChecker.from_session(session)
reader = ReadingReader("readings.jsonl")
while not stop.is_set():
for reading in reader.poll():
checker.process(reading) # печатает WARNING при |Δ| > tolerance
stop.wait(0.2)
try:
while not stop.is_set():
for reading in reader.poll():
checker.process(reading) # печатает WARNING при |Δ| > tolerance
stop.wait(0.2)
except KeyboardInterrupt:
pass
finally:
stop.set()
worker.join() # завершить опрос до закрытия порта и файла
```
`LaserTemperatureChecker.evaluate(reading)` возвращает список
@@ -216,7 +226,7 @@ with LaserController(port="/dev/ttyUSB0") as ctrl, ReadingWriter("readings.jsonl
}
```
`readings.jsonl` (по одной строке-объекту на свип):
`readings.jsonl` (по одной строке-объекту на успешный опрос):
```json
{"seq":0,"mono_ns":123456789,"temp1":28.0,"temp2":28.9,"temp_ext1":22.0,"temp_ext2":23.0,"current1":33.0,"current2":35.0}
@@ -232,14 +242,16 @@ per_point_s = delay_time / 1000 + time_step / 1_000_000
sweep_period = num_steps × per_point_s
```
Монитор публикует одно показание за такой период. Если нужен другой темп —
Монитор опрашивает плату сразу после запуска, затем ждёт такой период после
каждого опроса; это оценка, без синхронизации с границами аппаратных свипов.
Если нужен другой темп —
`--strategy interval:<ms>`. (Внутренний счётчик `TO6` платы существует, но его
семантика не гарантирована, поэтому для тайминга он не используется.)
## Тесты
```bash
python -m pytest python_app/tests/test_laser_temp_monitoring.py -q
.venv/bin/python -m unittest python_app.tests.test_laser_temp_monitoring
```
Покрыто: round-trip сессии, tail JSONL (включая усечённую последнюю строку),
@@ -23,10 +23,15 @@ from .readings_channel import ReadingWriter, TemperatureReading
logger = logging.getLogger(__name__)
class _Temperatures(Protocol):
temp1: float
temp2: float
class _MeasurementSource(Protocol):
"""Minimal controller surface the monitor depends on (eases testing)."""
def get_measurements(self) -> object: ...
def get_measurements(self) -> _Temperatures | None: ...
def compute_sweep_period_s(
@@ -138,5 +143,5 @@ class LaserTemperatureMonitor:
logger.info("Temperature monitor stopped after %d readings", seq)
def _opt(value: object) -> Optional[float]:
def _opt(value: float | None) -> Optional[float]:
return None if value is None else float(value)
@@ -58,7 +58,7 @@ class TemperatureReading:
)
def _opt_float(value: object) -> Optional[float]:
def _opt_float(value: str | float | None) -> Optional[float]:
return None if value is None else float(value)
@@ -6,7 +6,6 @@ serial port connection to the laser control board.
import errno
import logging
import struct
from typing import Optional
from enum import IntEnum
from datetime import datetime
@@ -30,7 +29,7 @@ from .conversions import (
current_ma_to_n, current_n_to_ma,
voltage_3v3_n_to_v, voltage_5v_n_to_v, voltage_7v_n_to_v,
)
from .models import Measurements, VariationType
from .models import Measurements
from .exceptions import (
CommunicationError,
PortNotFoundError,
@@ -42,16 +41,6 @@ from .exceptions import (
logger = logging.getLogger(__name__)
# Re-export enums so tests can import from protocol module
class CommandCode(IntEnum):
DECODE_ENABLE = CMD_DECODE_ENABLE
DEFAULT_ENABLE = CMD_DEFAULT_ENABLE
TRANS_ENABLE = CMD_TRANS_ENABLE
REMOVE_FILE = CMD_REMOVE_FILE
STATE = CMD_STATE
TASK_ENABLE = CMD_TASK_ENABLE
class TaskType(IntEnum):
MANUAL = 0x00
CHANGE_CURRENT_LD1 = 0x01
@@ -60,17 +49,6 @@ class TaskType(IntEnum):
CHANGE_TEMPERATURE_LD2 = 0x04
class DeviceState(IntEnum):
IDLE = 0x0000
RUNNING = 0x0001
BUSY = 0x0002
ERROR = 0x00FF
ERROR_OVERHEAT = 0x0100
ERROR_POWER = 0x0200
ERROR_COMMUNICATION = 0x0400
ERROR_INVALID_COMMAND = 0x0800
# ---- Low-level helpers --------------------------------------------------
def _int_to_hex4(value: int) -> str:
@@ -156,20 +134,6 @@ class Response:
)
# ---- Message builder --------------------------------------------------
class Message:
"""Named container for an encoded command byte array."""
def __init__(self, data: bytearray):
self._data = data
def to_bytes(self) -> bytes:
return bytes(self._data)
def __len__(self):
return len(self._data)
# ---- Protocol class --------------------------------------------------
class Protocol:
@@ -364,17 +328,17 @@ class Protocol:
@staticmethod
def encode_trans_enable(message_id: int = 0) -> bytes:
"""Build TRANS_ENABLE command (0x4444) — request last data."""
return bytearray.fromhex(_flipfour(_int_to_hex4(CMD_TRANS_ENABLE)))
return bytes.fromhex(_flipfour(_int_to_hex4(CMD_TRANS_ENABLE)))
@staticmethod
def encode_state(message_id: int = 0) -> bytes:
"""Build STATE command (0x6666) — request device state."""
return bytearray.fromhex(_flipfour(_int_to_hex4(CMD_STATE)))
return bytes.fromhex(_flipfour(_int_to_hex4(CMD_STATE)))
@staticmethod
def encode_default_enable(message_id: int = 0) -> bytes:
"""Build DEFAULT_ENABLE command (0x2222) — reset device."""
return bytearray.fromhex(_flipfour(_int_to_hex4(CMD_DEFAULT_ENABLE)))
return bytes.fromhex(_flipfour(_int_to_hex4(CMD_DEFAULT_ENABLE)))
# ---- Response decoders -----------------------------------------------
@@ -433,7 +397,7 @@ class Protocol:
Decode a 2-byte STATE response from the device.
Returns:
Integer state code (compare with DeviceState enum).
Integer state code; see STATE_* constants.
"""
if len(data) < 2:
raise ProtocolError(f"STATE response too short: {len(data)} bytes")
@@ -1,106 +0,0 @@
"""Backend adapters used by :mod:`python_app.hardware_full.librevna_service`."""
from __future__ import annotations
from dataclasses import dataclass, field
from contextlib import suppress
from typing import Any
import numpy as np
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.run_config_model import RadarSweepModel
@dataclass(slots=True)
class NativeLibreVnaBackend:
"""Native backend using direct USB LibreVNA Python driver."""
serial: str | None
strict_protocol_version: int
_device: Any | None = field(init=False, default=None, repr=False)
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
_libre_vna_device: type[Any] = field(init=False, repr=False)
_vna_sweep_settings: type[Any] = field(init=False, repr=False)
def __post_init__(self) -> None:
"""Import native driver classes lazily and initialize backend state."""
from python_app.hardware_full.librevna_driver import LibreVNADevice, VNASweepSettings
self._libre_vna_device = LibreVNADevice
self._vna_sweep_settings = VNASweepSettings
def open(self) -> None:
"""Open device connection if it is not already open."""
if self._device is not None:
return
device = self._device = self._libre_vna_device()
try:
device.connect(serial=self.serial, strict_protocol_version=self.strict_protocol_version, timeout_s=2.0)
except Exception:
with suppress(Exception):
self.close()
raise
@property
def is_open(self) -> bool:
"""Return `True` when native device connection is open."""
return self._device is not None
def close(self) -> None:
"""Close device connection when open."""
if self._device is None:
return
device, self._device = self._device, None
device.disconnect()
def configure(self, sweep: RadarSweepModel) -> None:
"""Store and apply sweep settings to connected hardware."""
self._settings = sweep
if self._device is None:
return
settings = self._vna_sweep_settings(
f_start_hz=sweep.start_hz,
f_stop_hz=sweep.stop_hz,
points=sweep.points,
if_bandwidth_hz=sweep.if_bandwidth_hz,
power_start_dbm=sweep.power_dbm,
power_stop_dbm=sweep.power_dbm,
excited_ports=(1, 2),
)
self._device.vna.configure(settings)
def read_device_limits(self) -> dict[str, float | int]:
"""Read frequency/IFBW/power/points limits from connected device."""
if self._device is None:
raise RuntimeError("Failed to connect to LibreVNA device")
device_info = self._device.get_device_info()
limits = device_info.limits
return {
"min_frequency_hz": float(limits.min_frequency_hz),
"max_frequency_hz": float(limits.max_frequency_hz),
"min_ifbw_hz": float(limits.min_ifbw_hz),
"max_ifbw_hz": float(limits.max_ifbw_hz),
"max_points": int(limits.max_points),
"min_power_dbm": float(limits.min_power_dbm),
"max_power_dbm": float(limits.max_power_dbm),
}
def acquire(self) -> SweepResult:
"""Acquire one sweep from hardware."""
if self._settings is None:
raise RuntimeError("Radar service is not configured")
if self._device is None:
raise RuntimeError("Device not found")
result = self._device.vna.acquire(expected_points=self._settings.points, timeout_s=20.0)
return SweepResult(
x=np.asarray(result.x, dtype=np.float32),
traces={
"s11": np.asarray(result.trace("s11"), dtype=np.complex64),
"s21": np.asarray(result.trace("s21"), dtype=np.complex64),
},
)
@@ -5,7 +5,6 @@ import logging
from .enums import (
HardwareFamily,
PacketType,
SParameter,
SweepKind,
SweepScale,
SyncMode,
@@ -53,7 +52,6 @@ __all__ = [
"PacketType",
"ParseError",
"ProtocolVersionMismatch",
"SParameter",
"StreamHandle",
"SweepKind",
"SweepResult",
@@ -1,11 +0,0 @@
"""Public controller classes."""
from .config import ConfigController
from .generator import GeneratorController
from .vna import VNAController
__all__ = [
"ConfigController",
"GeneratorController",
"VNAController",
]
@@ -4,13 +4,12 @@ from __future__ import annotations
from dataclasses import replace
import logging
from typing import Callable
from ..enums import PacketType
from ..enums import PacketType, SyncMode
from ..exceptions import ParseError
from ..models import Packet, StreamHandle, SweepResult, VNADatapointPacket, VNARawPoint, VNASweepSettings
from ..models import Packet, SweepResult, VNADatapointPacket, VNASweepSettings
from ..session import LibreVNASession
from ..sweep.assembler import assemble_vna_sweep, datapoint_to_raw_point
from ..sweep.assembler import assemble_vna_sweep
logger = logging.getLogger(__name__)
@@ -27,22 +26,21 @@ class VNAController:
"""Send `SweepSettings` packet to configure VNA operation."""
effective = settings
if effective.sync_mode is None:
effective = replace(effective, sync_mode=self._session.default_sync_mode)
effective = replace(effective, sync_mode=SyncMode.DISABLED)
self._settings = effective
self._session.clear_queue(PacketType.VNA_DATAPOINT)
self._session.send(Packet(PacketType.SWEEP_SETTINGS, effective), require_ack=True)
logger.info(
"VNA configured: kind=%s start=%.3fHz stop=%.3fHz points=%d ifbw=%.3fHz ports=%s",
"VNA configured: kind=%s start=%.3fHz stop=%.3fHz points=%d ifbw=%.3fHz",
effective.kind.value,
effective.f_start_hz,
effective.f_stop_hz,
effective.points,
effective.if_bandwidth_hz,
effective.excited_ports,
)
def acquire(self, *, expected_points: int | None = None, timeout_s: float = 10.0) -> SweepResult:
def acquire(self, *, expected_points: int | None = None, timeout_s: float | None = None) -> SweepResult:
"""Acquire one complete sweep and return assembled complex traces."""
if self._settings is None:
raise ParseError("VNA is not configured. Call vna.configure() before acquire().")
@@ -52,6 +50,13 @@ class VNAController:
if points_target <= 0:
raise ValueError("expected_points must be > 0")
# Firmware integrates for 1/IFBW after dwell/PLL settling at every point.
# Allow a discarded free-running tail and USB/host overhead, but detect
# a stalled instrument independently of this whole-sweep budget.
point_seconds = 1.0 / settings.if_bandwidth_hz + settings.dwell_s + 0.001
if timeout_s is None:
timeout_s = max(10.0, 2.0 * points_target * point_seconds + 2.0)
logger.info(
"VNA acquire start: expected_points=%d timeout=%.2fs standby=%s",
points_target,
@@ -66,6 +71,7 @@ class VNAController:
packet_type=PacketType.VNA_DATAPOINT,
expected_points=points_target,
timeout_s=timeout_s,
progress_timeout_s=max(1.5, 3.0 * point_seconds),
payload_type=VNADatapointPacket,
payload_error="Expected decoded VNADatapointPacket payload",
)
@@ -79,21 +85,3 @@ class VNAController:
)
logger.info("VNA acquire complete: received_points=%d", len(result.x))
return result
def stream(self, callback: Callable[[VNARawPoint], None]) -> StreamHandle:
"""Subscribe callback for every incoming VNA datapoint packet."""
if self._settings is None:
raise ParseError("VNA is not configured. Call vna.configure() before stream().")
settings = self._settings
num_ports = self._session.get_device_info().num_ports
def _on_packet(packet: Packet) -> None:
"""Decode datapoint packet and forward mapped raw point to callback."""
payload = packet.payload
if not isinstance(payload, VNADatapointPacket):
raise ParseError("Expected decoded VNADatapointPacket payload")
callback(datapoint_to_raw_point(payload, settings, num_ports=num_ports))
logger.info("VNA stream subscription started")
return self._session.subscribe(PacketType.VNA_DATAPOINT, _on_packet)
@@ -76,12 +76,3 @@ class SweepScale(str, Enum):
LIN = "lin"
LOG = "log"
class SParameter(str, Enum):
"""Canonical 2-port S-parameters."""
S11 = "S11"
S12 = "S12"
S21 = "S21"
S22 = "S22"
@@ -89,7 +89,6 @@ class VNASweepSettings:
if_bandwidth_hz: float = 1_000.0
power_start_dbm: float = -10.0
power_stop_dbm: float = -10.0
excited_ports: tuple[int, ...] = (1, 2)
sweep_scale: SweepScale = SweepScale.LIN
dwell_s: float = 0.0
suppress_invalid_peaks: bool = True
@@ -106,12 +105,6 @@ class VNASweepSettings:
raise ValueError("if_bandwidth_hz must be > 0")
if self.dwell_s < 0:
raise ValueError("dwell_s must be >= 0")
if not self.excited_ports:
raise ValueError("excited_ports must not be empty")
if len(set(self.excited_ports)) != len(self.excited_ports):
raise ValueError("excited_ports must not contain duplicates")
if any(port <= 0 for port in self.excited_ports):
raise ValueError("excited_ports must use 1-based positive port numbers")
if self.f_stop_hz < self.f_start_hz:
raise ValueError("f_stop_hz must be >= f_start_hz")
if self.kind == SweepKind.POWER and self.power_stop_dbm < self.power_start_dbm:
@@ -217,60 +210,14 @@ class SweepResult:
normalized[key.strip().lower()] = arr
self.traces = normalized
@property
def s11(self) -> np.ndarray | None:
"""Return `S11` trace when available."""
return self.traces.get("s11")
@property
def s21(self) -> np.ndarray | None:
"""Return `S21` trace when available."""
return self.traces.get("s21")
@property
def s12(self) -> np.ndarray | None:
"""Return `S12` trace when available."""
return self.traces.get("s12")
@property
def s22(self) -> np.ndarray | None:
"""Return `S22` trace when available."""
return self.traces.get("s22")
def trace(self, parameter: str) -> np.ndarray:
"""Return complex trace by parameter name."""
key = parameter.strip().lower()
if key not in self.traces:
raise KeyError(f"Trace '{parameter}' is not available")
return self.traces[key]
def real(self, parameter: str) -> np.ndarray:
"""Return real part of selected trace."""
return self.trace(parameter).real
def imag(self, parameter: str) -> np.ndarray:
"""Return imaginary part of selected trace."""
return self.trace(parameter).imag
def to_npz(self, path: str) -> None:
"""Save the axis and all traces to a NumPy `.npz` archive at ``path``."""
data: dict[str, np.ndarray] = {self.x_label: self.x}
data.update(self.traces)
np.savez(path, **data)
logger.debug("Saved SweepResult to NPZ: %s (traces=%d)", path, len(self.traces))
def to_csv(self, path: str) -> None:
"""Save the axis and traces to CSV at ``path``, with `<trace>_real`/`<trace>_imag` columns."""
columns: list[np.ndarray] = [self.x]
headers: list[str] = [self.x_label]
for name, values in sorted(self.traces.items()):
columns.append(values.real)
columns.append(values.imag)
headers.append(f"{name}_real")
headers.append(f"{name}_imag")
matrix = np.column_stack(columns)
np.savetxt(path, matrix, delimiter=",", header=",".join(headers), comments="")
logger.debug("Saved SweepResult to CSV: %s (traces=%d)", path, len(self.traces))
PacketPayload = Any
@@ -239,12 +239,8 @@ def decode_packet_payload(packet: Packet) -> object:
def encode_sweep_settings_payload(settings: VNASweepSettings) -> bytes:
"""Encode `SweepSettings` payload."""
if len(settings.excited_ports) > 4:
raise ValueError("Protocol supports at most four excited ports")
sync_mode = settings.sync_mode if settings.sync_mode is not None else 0
stage_by_port = {port: stage for stage, port in enumerate(settings.excited_ports)}
flags1 = 0
flags1 |= int(bool(settings.standby)) << 0
flags1 |= int(bool(settings.sync_master)) << 1
@@ -253,13 +249,9 @@ def encode_sweep_settings_payload(settings: VNASweepSettings) -> bytes:
flags1 |= int(settings.sweep_scale.value == "log") << 4
flags1 |= (int(sync_mode) & 0x03) << 5
flags2 = 0
stages = len(settings.excited_ports) - 1
flags2 |= stages & 0x07
flags2 |= (stage_by_port.get(1, 0) & 0x07) << 3
flags2 |= (stage_by_port.get(2, 0) & 0x07) << 6
flags2 |= (stage_by_port.get(3, 0) & 0x07) << 9
flags2 |= (stage_by_port.get(4, 0) & 0x07) << 12
# One stage, stimulus on port 1; stage 7 disables port 2.
# port3Stage must stay zero: our firmware uses it for substage width.
flags2 = 7 << 6
dwell_us = int(round(settings.dwell_s * 1_000_000.0))
dwell_us = max(0, min(0xFFFF, dwell_us))
@@ -8,7 +8,7 @@ import threading
import time
from typing import Callable, Protocol, TypeVar
from .enums import HardwareFamily, PacketType, SyncMode
from .enums import HardwareFamily, PacketType
from .exceptions import (
DeviceDisconnectedError,
NackError,
@@ -69,9 +69,6 @@ class LibreVNASession:
self._device_status: DeviceStatus | None = None
self._hardware_family: HardwareFamily = HardwareFamily.UNKNOWN
self._default_sync_mode = SyncMode.DISABLED
self._default_sync_master = False
@property
def is_connected(self) -> bool:
"""Return `True` when USB connection is active."""
@@ -87,21 +84,6 @@ class LibreVNASession:
"""Hardware family inferred from `DeviceInfo` packet."""
return self._hardware_family
@property
def default_sync_mode(self) -> SyncMode:
"""Default sync mode used by controllers when settings do not override."""
return self._default_sync_mode
@property
def default_sync_master(self) -> bool:
"""Default sync master flag used by controllers."""
return self._default_sync_master
def set_default_sync(self, mode: SyncMode, master: bool) -> None:
"""Set session-level default synchronization settings."""
self._default_sync_mode = mode
self._default_sync_master = master
@staticmethod
def list_devices() -> list[USBDeviceDescriptor]:
"""Enumerate available direct-USB LibreVNA devices."""
@@ -241,6 +223,7 @@ class LibreVNASession:
timeout_s: float,
payload_type: type[TPacketPayload],
payload_error: str,
progress_timeout_s: float | None = None,
) -> list[TPacketPayload]:
"""Collect one sweep from point zero, returning points in ascending order.
@@ -251,11 +234,14 @@ class LibreVNASession:
raise ValueError("expected_points must be > 0")
deadline = time.monotonic() + timeout_s
progress_timeout_s = timeout_s if progress_timeout_s is None else progress_timeout_s
progress_deadline = time.monotonic() + progress_timeout_s
previous_point: int | None = None
collected: list[TPacketPayload | None] = [None] * expected_points
received = 0
while received < expected_points:
remaining = deadline - time.monotonic()
remaining = min(deadline, progress_deadline) - time.monotonic()
if remaining <= 0:
break
@@ -270,6 +256,10 @@ class LibreVNASession:
f"Received out-of-range point index {point_number}, expected 0..{expected_points - 1}"
)
if point_number != previous_point:
progress_deadline = time.monotonic() + progress_timeout_s
previous_point = point_number
if point_number == 0:
collected = [None] * expected_points
received = 0
@@ -1,10 +0,0 @@
"""Sweep assembly and result helpers."""
from .assembler import assemble_vna_sweep, datapoint_to_raw_point
from ..models import SweepResult
__all__ = [
"SweepResult",
"assemble_vna_sweep",
"datapoint_to_raw_point",
]
@@ -32,12 +32,12 @@ def _find_vna_value(
) -> complex:
"""Find one complex receiver value for stage/port/ref tuple."""
source_mask = 1 << port_index
if reference:
source_mask |= _REFERENCE_FLAG
for idx, flags in enumerate(datapoint.flags):
if _extract_stage(int(flags)) != stage:
continue
if bool(int(flags) & _REFERENCE_FLAG) != reference:
continue
if (int(flags) & source_mask) != source_mask:
continue
return complex(float(datapoint.real[idx]), float(datapoint.imag[idx]))
@@ -48,6 +48,14 @@ def _find_vna_value(
)
def _s21(datapoint: VNADatapointPacket) -> complex:
reference = _find_vna_value(datapoint, stage=0, port_index=0, reference=True)
receiver = _find_vna_value(datapoint, stage=0, port_index=1, reference=False)
if not np.isfinite(reference) or abs(reference) <= 1e-12 or not np.isfinite(receiver):
raise ParseError(f"Invalid receiver/reference at point {datapoint.point_number}")
return receiver / reference
def datapoint_to_raw_point(
datapoint: VNADatapointPacket,
settings: VNASweepSettings,
@@ -55,19 +63,9 @@ def datapoint_to_raw_point(
num_ports: int,
) -> VNARawPoint:
"""Convert low-level VNADatapoint packet into callback-friendly object."""
measurements: dict[str, complex] = {}
stage_by_excited_port = {port: stage for stage, port in enumerate(settings.excited_ports)}
for excited_port, stage in stage_by_excited_port.items():
ref = _find_vna_value(datapoint, stage=stage, port_index=excited_port - 1, reference=True)
for receiver_port in range(1, num_ports + 1):
measured = _find_vna_value(
datapoint,
stage=stage,
port_index=receiver_port - 1,
reference=False,
)
measurements[f"S{receiver_port}{excited_port}"] = measured / ref
if num_ports < 2:
raise ParseError("S21 acquisition requires two receiver ports")
measurements = {"S21": _s21(datapoint)}
zero_span = settings.f_start_hz == settings.f_stop_hz and math.isclose(
settings.power_start_dbm,
@@ -108,17 +106,9 @@ def assemble_vna_sweep(
x = np.empty(expected_points, dtype=np.float64)
seen = np.zeros(expected_points, dtype=bool)
stage_by_excited_port = {port: stage for stage, port in enumerate(settings.excited_ports)}
receiver_limit = min(num_ports, 2)
trace_names = [
f"s{receiver_port}{excited_port}"
for excited_port in settings.excited_ports
for receiver_port in range(1, receiver_limit + 1)
if receiver_port <= 2 and excited_port <= 2
]
traces: dict[str, np.ndarray] = {
name: np.empty(expected_points, dtype=np.complex128) for name in trace_names
}
if num_ports < 2:
raise ParseError("S21 acquisition requires two receiver ports")
s21 = np.empty(expected_points, dtype=np.complex128)
zero_span = settings.f_start_hz == settings.f_stop_hz and math.isclose(
settings.power_start_dbm,
@@ -130,7 +120,7 @@ def assemble_vna_sweep(
for datapoint in datapoints:
idx = datapoint.point_number
if idx >= expected_points:
if not 0 <= idx < expected_points:
raise ParseError(f"Received out-of-range point index {idx}, expected < {expected_points}")
if zero_span:
@@ -140,22 +130,7 @@ def assemble_vna_sweep(
else:
x[idx] = float(datapoint.frequency_or_time)
for excited_port, stage in stage_by_excited_port.items():
ref = _find_vna_value(datapoint, stage=stage, port_index=excited_port - 1, reference=True)
for receiver_port in range(1, num_ports + 1):
if receiver_port > 2 or excited_port > 2:
# Public SweepResult intentionally exposes 2-port canonical traces.
continue
measured = _find_vna_value(
datapoint,
stage=stage,
port_index=receiver_port - 1,
reference=False,
)
trace_name = f"s{receiver_port}{excited_port}"
trace = traces.get(trace_name)
if trace is not None:
trace[idx] = measured / ref
s21[idx] = _s21(datapoint)
seen[idx] = True
@@ -171,7 +146,7 @@ def assemble_vna_sweep(
elif power_sweep:
x_label = "power_dbm"
result = SweepResult(x=x, traces=traces, x_label=x_label)
result = SweepResult(x=x, traces={"s21": s21}, x_label=x_label)
logger.debug(
"Assembled VNA sweep: points=%d x_label=%s traces=%s",
len(result.x),
@@ -6,7 +6,7 @@ from contextlib import suppress
import logging
import threading
import time
from typing import Callable
from typing import Callable, cast
from ..exceptions import DeviceDisconnectedError, TimeoutError
from ..models import USBDeviceDescriptor
@@ -40,8 +40,9 @@ class USBTransport:
self._on_disconnect = on_disconnect
self._read_chunk_size = read_chunk_size
self._ctx: usb1.USBContext | None = None # type: ignore[valid-type]
self._handle: usb1.USBDeviceHandle | None = None # type: ignore[valid-type]
self._ctx: usb1.USBContext | None = None
# libusb1 binds close() to a zero-argument finalizer on each handle.
self._handle: usb1.USBDeviceHandle | None = None
self._rx_thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._tx_lock = threading.Lock()
@@ -78,7 +79,7 @@ class USBTransport:
finally:
if handle is not None:
with suppress(usb1.USBError):
handle.close()
cast(Callable[[], None], handle.close)()
devices.append(USBDeviceDescriptor(serial=serial, vendor_id=vid, product_id=pid))
@@ -114,7 +115,7 @@ class USBTransport:
finally:
if handle is not None and handle is not selected_handle:
with suppress(usb1.USBError):
handle.close()
cast(Callable[[], None], handle.close)()
if selected_handle is None:
serial_msg = f" with serial '{serial}'" if serial else ""
@@ -149,7 +150,7 @@ class USBTransport:
self.disconnect()
raise
def _claim_interface_with_busy_recovery(self, handle: "usb1.USBDeviceHandle") -> None: # type: ignore[name-defined]
def _claim_interface_with_busy_recovery(self, handle: "usb1.USBDeviceHandle") -> None:
"""Claim the data interface, clearing a stale BUSY claim once if needed.
When a previous session's RX thread wedged inside libusb, ``disconnect()``
@@ -163,7 +164,9 @@ class USBTransport:
try:
handle.claimInterface(self.INTERFACE)
return
except usb1.USBErrorBusy as exc:
except usb1.USBError as exc:
if exc.value != -6: # LIBUSB_ERROR_BUSY
raise
logger.warning(
"USB interface %d busy on claim; resetting device to clear a stale claim: %s",
self.INTERFACE,
@@ -202,7 +205,7 @@ class USBTransport:
with suppress(usb1.USBError):
self._handle.releaseInterface(self.INTERFACE)
with suppress(usb1.USBError):
self._handle.close()
cast(Callable[[], None], self._handle.close)()
self._handle = None
if self._ctx is not None:
@@ -223,11 +226,9 @@ class USBTransport:
with self._tx_lock:
try:
written = handle.bulkWrite(self.DATA_EP_OUT, data, timeout=timeout_ms)
except usb1.USBErrorTimeout as exc:
raise TimeoutError("Timed out writing USB bulk packet") from exc
except usb1.USBErrorNoDevice as exc:
raise DeviceDisconnectedError("USB device disconnected during write") from exc
except usb1.USBError as exc:
if exc.value == -7: # LIBUSB_ERROR_TIMEOUT
raise TimeoutError("Timed out writing USB bulk packet") from exc
raise DeviceDisconnectedError(f"USB write failed: {exc}") from exc
if written != len(data):
@@ -248,16 +249,9 @@ class USBTransport:
while not self._stop_event.is_set():
try:
data = handle.bulkRead(self.DATA_EP_IN, self._read_chunk_size, timeout=timeout_ms)
except usb1.USBErrorTimeout:
continue
except usb1.USBErrorInterrupted:
continue
except usb1.USBErrorNoDevice as exc:
if self._on_disconnect is not None:
self._on_disconnect(DeviceDisconnectedError(f"USB device disconnected: {exc}"))
logger.warning("USB RX stopped: device disconnected: %s", exc)
return
except usb1.USBError as exc:
if exc.value in (-7, -10): # LIBUSB_ERROR_TIMEOUT / INTERRUPTED
continue
if self._stop_event.is_set():
return
if self._on_disconnect is not None:
@@ -294,7 +288,7 @@ class USBTransport:
@classmethod
def _iter_matching_devices(
cls,
ctx: "usb1.USBContext", # type: ignore[name-defined]
ctx: "usb1.USBContext",
):
"""Yield USB devices matching supported LibreVNA VID/PID pairs."""
for device in ctx.getDeviceList(skip_on_error=True):
@@ -1,23 +0,0 @@
"""Synchronized multi-device LibreVNA driver."""
from python_app.hardware_full.librevna_multi_device_driver.models import SweepConfiguration, SweepMeasurementResult
__all__ = [
"MultiDeviceVnaController",
"SweepConfiguration",
"SweepMeasurementResult",
"list_connected_device_serial_numbers",
]
def __getattr__(name: str):
"""Lazily import native USB pieces only when they are actually needed."""
if name == "MultiDeviceVnaController":
from python_app.hardware_full.librevna_multi_device_driver.controller import MultiDeviceVnaController
return MultiDeviceVnaController
if name == "list_connected_device_serial_numbers":
from python_app.hardware_full.librevna_multi_device_driver.transport import list_connected_device_serial_numbers
return list_connected_device_serial_numbers
raise AttributeError(name)
@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Iterator, Sequence
from collections.abc import Sequence
from contextlib import suppress
from dataclasses import replace
from typing import Optional
@@ -11,7 +11,7 @@ import threading
import time
from python_app.hardware_full.librevna_multi_device_driver.cycle_collection import (
collect_complete_running_sweep_cycles,
collect_complete_running_sweep_cycle,
)
from python_app.hardware_full.librevna_multi_device_driver.models import (
SweepConfiguration,
@@ -109,13 +109,6 @@ class MultiDeviceVnaController:
device_connection.close()
logger.debug("Multi-device controller closed")
def stop_continuous_sweep(self) -> None:
"""Stop the currently running sweep without closing device transports."""
if self._is_closed:
return
logger.info("Stopping continuous sweep")
self._send_idle_to_all_devices()
def configure_continuous_sweep(
self,
sweep_configuration: SweepConfiguration,
@@ -179,13 +172,12 @@ class MultiDeviceVnaController:
master_stimulus_ports=stimulus_ports,
)
def collect_running_sweep_cycles(
def collect_running_sweep_cycle(
self,
cycle_count: int = 1,
*,
datapoint_timeout_seconds: float | None = None,
) -> SweepMeasurementResult:
"""Collect complete cycles from the already-running synchronized sweep."""
"""Collect one complete cycle from the already-running synchronized sweep."""
if self._is_closed:
raise RuntimeError("Controller is already closed")
if (
@@ -197,11 +189,10 @@ class MultiDeviceVnaController:
raise RuntimeError("No running sweep is configured. Call configure_continuous_sweep() first.")
try:
return collect_complete_running_sweep_cycles(
return collect_complete_running_sweep_cycle(
master_device_connection=self._master_device,
slave_device_connections=self._slave_devices,
active_sweep_configuration=self._last_applied_sweep_configuration,
cycle_count=cycle_count,
master_stimulus_ports=self._last_master_stimulus_ports,
switch_substage_count=self._switch_substage_count,
datapoint_timeout_seconds=datapoint_timeout_seconds,
@@ -211,11 +202,6 @@ class MultiDeviceVnaController:
self._send_idle_to_all_devices()
raise
def iter_running_sweep_cycles(self) -> Iterator[SweepMeasurementResult]:
"""Yield complete sweep cycles forever from the configured sweep."""
while True:
yield self.collect_running_sweep_cycles(1)
def _send_command_and_wait_for_acknowledgement(
self,
device_connection: LibreVnaUsbBulkConnection,
@@ -14,7 +14,6 @@ from python_app.hardware_full.librevna_multi_device_driver.exceptions import (
TransientCollectionError,
)
from python_app.hardware_full.librevna_multi_device_driver.models import (
OutputChannel,
SweepConfiguration,
SweepMeasurementResult,
build_output_channels,
@@ -52,7 +51,7 @@ _MIN_CYCLE_ALIGNMENT_TOLERANCE_SECONDS = 0.5e-3
# Below this magnitude a master reference value is treated as a corrupt incident
# signal rather than a real measurement: dividing by it would explode the
# S-parameter into noise. Mirrors the single-device C++ driver's reference guard.
# S-parameter into noise. Both acquisition drivers reject such references.
_MIN_REFERENCE_MAGNITUDE = 1e-12
# A dropped USB frame leaves a single sweep point undelivered. Instead of
@@ -76,10 +75,12 @@ class _DeviceCollectionState:
points are zero-filled when the S-parameters are computed.
"""
__slots__ = ("last_cycle_closed",)
__slots__ = ("last_cycle_closed", "capture_start_ns", "capture_end_ns")
def __init__(self) -> None:
self.last_cycle_closed = False
self.capture_start_ns = 0
self.capture_end_ns = 0
class _CrossDeviceCycleSynchronizer:
@@ -168,20 +169,16 @@ def _is_transient_collection_error(error: BaseException) -> bool:
return isinstance(error, (TransientCollectionError, TimeoutError))
def collect_complete_running_sweep_cycles(
def collect_complete_running_sweep_cycle(
*,
master_device_connection: LibreVnaUsbBulkConnection,
slave_device_connections: Sequence[LibreVnaUsbBulkConnection],
active_sweep_configuration: SweepConfiguration,
cycle_count: int,
master_stimulus_ports: Sequence[int],
switch_substage_count: int = 1,
datapoint_timeout_seconds: float | None = None,
) -> SweepMeasurementResult:
"""Collect complete cycles from an already-running synchronized sweep."""
cycle_count = int(cycle_count)
if cycle_count < 1:
raise ValueError("cycle_count must be >= 1")
"""Collect one complete cycle from an already-running synchronized sweep."""
stimulus_ports = normalize_master_stimulus_ports(master_stimulus_ports)
# One output per (master port, stimulus switch position). With no per-point
# switching this is one output per port, exactly as before.
@@ -202,18 +199,14 @@ def collect_complete_running_sweep_cycles(
)
else:
frequencies_hz = np.array([float(active_sweep_configuration.start_hz)], dtype=np.float64)
# Keyed by 1-based OUTPUT number, not by master port: with per-point switching two
# outputs share a stimulus port but are measured in different stages, so each needs
# its own incident reference.
master_reference_measurements_by_output = {
channel.number: np.full((cycle_count, point_count), np.nan + 1j * np.nan, dtype=complex)
channel.index: np.full(point_count, np.nan + 1j * np.nan, dtype=complex)
for channel in output_channels
}
raw_receiver_measurements_by_s_parameter = {
name: np.full((cycle_count, point_count), np.nan + 1j * np.nan, dtype=complex)
for name in build_forward_s_parameter_names(
len(slave_device_connections), output_channels
)
(input_pos, channel.index): np.full(point_count, np.nan + 1j * np.nan, dtype=complex)
for channel in output_channels
for input_pos in range(2 * len(slave_device_connections))
}
collection_errors: list[Exception] = []
@@ -315,12 +308,12 @@ def collect_complete_running_sweep_cycles(
``stop_collection_requested`` is set so the other collector threads stop too.
"""
datapoints_received = 0
expected_datapoint_count = cycle_count * point_count
expected_datapoint_count = point_count
loop_start_timestamp = time.monotonic()
# Tracks the last time we actually accepted a datapoint into the cycle.
# Crucially, *not* updated on rejected datapoints — a device that keeps
# streaming valid-looking frames the handler ignores (e.g. waiting on
# point_index=0, or after cycle_count has been reached) must still hit
# point_index=0, or after the cycle has closed) must still hit
# the per-device timeout and trigger recovery instead of looping forever.
last_consumed_timestamp = loop_start_timestamp
has_consumed_any_datapoint = False
@@ -449,15 +442,14 @@ def collect_complete_running_sweep_cycles(
datapoints_received += 1
def build_cycle_tracking_handler(
cycle_aware_handler: Callable[[ParsedVnaDatapoint, int], None],
cycle_aware_handler: Callable[[ParsedVnaDatapoint], None],
device_state: _DeviceCollectionState,
device_label: str = "device",
) -> Callable[[ParsedVnaDatapoint], bool]:
"""Wrap a cycle-aware handler with cross-device cycle tracking.
Returns a per-datapoint handler that anchors cycle 0 on the first
``point_index == 0`` seen, advances the cycle counter on each point-index
wrap, drops datapoints past ``cycle_count``, and reports whether each
``point_index == 0`` seen, closes on the next point-index wrap, and reports whether each
datapoint was consumed.
"""
# In steady state the controller leaves the hardware sweep FREE-RUNNING
@@ -468,10 +460,8 @@ def collect_complete_running_sweep_cycles(
# the drain can let a wrap slip between devices, that first point 0 is only
# accepted once `_CrossDeviceCycleSynchronizer` confirms every device saw
# point 0 of the SAME physical sweep; a mismatch rejects the collection for
# a transient retry. From the agreed anchor, each subsequent wrap advances
# the cycle counter normally.
# a transient retry. The next wrap closes the agreed cycle.
cycle_tracking_state = {
"current_cycle_index": 0,
"synchronized": False,
# How many mid-sweep points were thrown away before the anchor was found.
# Near zero means the drain landed on a sweep boundary — the case where a
@@ -483,11 +473,12 @@ def collect_complete_running_sweep_cycles(
def handle_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> bool:
"""Track the cycle index for one datapoint and dispatch it to the handler.
Returns ``True`` when the datapoint was consumed (within ``cycle_count``)
Returns ``True`` when the datapoint was consumed (within the first cycle)
and ``False`` when it was ignored (pre-sync straggler or past the last
requested cycle).
"""
current_point_index = parsed_datapoint.point_index
received_ns = time.monotonic_ns()
if not cycle_tracking_state["synchronized"]:
if current_point_index != 0:
@@ -497,10 +488,11 @@ def collect_complete_running_sweep_cycles(
# observed point 0 of the SAME physical sweep; otherwise reject the
# whole collection so the caller re-drains and re-collects rather
# than pairing a reference and measurement from different sweeps.
if not cycle_synchronizer.confirm_aligned_cycle_zero(time.monotonic()):
if not cycle_synchronizer.confirm_aligned_cycle_zero(received_ns / 1e9):
report_cycle_misalignment()
return False
cycle_tracking_state["synchronized"] = True
device_state.capture_start_ns = device_state.capture_end_ns = received_ns
logger.debug(
"timing: %s anchored cycle 0 after skipping %d mid-sweep point(s) of %d "
"(t=%.1f ms)",
@@ -509,53 +501,21 @@ def collect_complete_running_sweep_cycles(
point_count,
time.monotonic() * 1e3,
)
cycle_aware_handler(parsed_datapoint, 0)
cycle_aware_handler(parsed_datapoint)
return True
# A new sweep cycle begins exactly when the point index wraps to 0.
# Keying on ==0 (rather than "the index decreased") means a corrupt
# mid-cycle index that slipped past the in-band gate cannot fabricate a
# spurious wrap and desynchronize the cycle counter.
if current_point_index == 0:
cycle_tracking_state["current_cycle_index"] += 1
logger.debug(
"timing: %s first point of NEXT sweep arrived (cycle -> %d, t=%.1f ms)",
device_label,
cycle_tracking_state["current_cycle_index"],
time.monotonic() * 1e3,
)
current_cycle_index = cycle_tracking_state["current_cycle_index"]
if current_cycle_index >= cycle_count:
# The sweep just wrapped past the final requested cycle, closing its
# collection window. Any of its points we never received are now
# confirmed dropped frames, not points still in flight, so flag the
# device done and let the collector thread stop immediately. The
# missing points are emitted as zero when the S-parameters are
# computed; delivered points keep their real values.
# A second point 0 closes this cycle; absent points are now losses.
device_state.last_cycle_closed = True
return False
cycle_aware_handler(parsed_datapoint, current_cycle_index)
if current_point_index == point_count - 1:
logger.debug(
"timing: %s last point of cycle %d arrived (index=%d, t=%.1f ms)",
device_label,
current_cycle_index,
current_point_index,
time.monotonic() * 1e3,
)
cycle_aware_handler(parsed_datapoint)
device_state.capture_end_ns = received_ns
return True
return handle_datapoint
def handle_master_datapoint(parsed_datapoint: ParsedVnaDatapoint, cycle_index: int) -> None:
"""Store the master device's frequency, reference, and reflection values.
Records the sweep-point frequency and, per output channel, the reference
receiver value and the matching reflection into the cycle/point measurement
buffers. Two outputs may share a stimulus port (different switch positions);
they are told apart by the stage tag.
"""
def handle_master_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> None:
"""Store the frequency and incident reference for each output stage."""
point_index = parsed_datapoint.point_index
frequencies_hz[point_index] = parsed_datapoint.frequency_hz
@@ -575,45 +535,20 @@ def collect_complete_running_sweep_cycles(
if reference_receiver_value is None:
continue
master_reference_measurements_by_output[channel.number][
cycle_index,
master_reference_measurements_by_output[channel.index][
point_index,
] = reference_receiver_value
s_parameter_name = master_reflection_s_parameter_name(channel)
port_receiver_value = find_receiver_value(
parsed_datapoint.receiver_values_by_description_mask,
stage_index=channel.stage_index,
is_reference_receiver=False,
required_port_number=channel.stimulus_port,
)
if port_receiver_value is not None:
raw_receiver_measurements_by_s_parameter[s_parameter_name][
cycle_index,
point_index,
] = port_receiver_value
def build_slave_datapoint_handler(
slave_index: int, device_state: _DeviceCollectionState
) -> Callable[[ParsedVnaDatapoint], bool]:
"""Build a cycle-tracking datapoint handler for the given slave device.
The slave's two receivers map to the virtual receiver ports numbered just
above the outputs (see :func:`slave_receiver_base_port`), producing one
forward S-parameter per output channel.
"""
receiver_base_port = slave_receiver_base_port(slave_index, output_channels)
def handle_slave_datapoint(parsed_datapoint: ParsedVnaDatapoint, cycle_index: int) -> None:
"""Collect both physical receivers of this slave in the configured order."""
def handle_slave_datapoint(parsed_datapoint: ParsedVnaDatapoint) -> None:
"""Store this slave's forward receiver values into the measurement buffers."""
point_index = parsed_datapoint.point_index
for channel in output_channels:
first_s_parameter_name = f"S{receiver_base_port}{channel.number}"
second_s_parameter_name = f"S{receiver_base_port + 1}{channel.number}"
for receiver_port_number, s_parameter_name in (
(1, first_s_parameter_name),
(2, second_s_parameter_name),
):
for receiver_port_number in (1, 2):
combo = (2 * slave_index + receiver_port_number - 1, channel.index)
port_receiver_value = find_receiver_value(
parsed_datapoint.receiver_values_by_description_mask,
stage_index=channel.stage_index,
@@ -621,8 +556,7 @@ def collect_complete_running_sweep_cycles(
required_port_number=receiver_port_number,
)
if port_receiver_value is not None:
raw_receiver_measurements_by_s_parameter[s_parameter_name][
cycle_index,
raw_receiver_measurements_by_s_parameter[combo][
point_index,
] = port_receiver_value
@@ -661,8 +595,7 @@ def collect_complete_running_sweep_cycles(
)
logger.debug(
"Collecting %d sweep cycle(s) of %d points from %d device(s) (datapoint_timeout=%.1fs)",
cycle_count,
"Collecting one sweep of %d points from %d device(s) (datapoint_timeout=%.1fs)",
point_count,
len(all_device_connections),
datapoint_timeout_seconds,
@@ -737,54 +670,20 @@ def collect_complete_running_sweep_cycles(
logger.debug("Sweep cycle collection complete (per-device counts: %s)", datapoint_counts_by_device_serial)
return SweepMeasurementResult(
frequencies_hz=frequencies_hz,
s_parameters=calculate_last_cycle_s_parameters(
s_parameters=calculate_s_parameters(
raw_receiver_measurements_by_s_parameter,
master_reference_measurements_by_output,
),
capture_start_ns=master_device_state.capture_start_ns,
capture_end_ns=master_device_state.capture_end_ns,
)
def slave_receiver_base_port(slave_index: int, output_channels: Sequence[OutputChannel]) -> int:
"""Return the first virtual receiver port number of one slave device.
Receivers are numbered directly above the outputs, so the whole topology reads
as one virtual VNA whose first ``N`` ports transmit and whose remaining ports
receive. Numbering them from a fixed 3 instead would make a 4-output setup
collide with itself: the reflection of output 3 and the forward term
``receiver 3 <- output 3`` would both be called ``S33``. With two outputs this
still yields the historical ports 3..6.
"""
return len(output_channels) + 2 * slave_index + 1
def master_reflection_s_parameter_name(channel: OutputChannel) -> str:
"""Return the reflection trace name for one output channel (``S11``, ``S22``, ...)."""
return f"S{channel.number}{channel.number}"
def build_forward_s_parameter_names(
slave_device_count: int, output_channels: Sequence[OutputChannel]
) -> list[str]:
"""Return S-parameter names for the configured forward multi-device topology."""
names: list[str] = []
for channel in output_channels:
names.append(master_reflection_s_parameter_name(channel))
for slave_index in range(slave_device_count):
receiver_base_port = slave_receiver_base_port(slave_index, output_channels)
names.extend(
[
f"S{receiver_base_port}{channel.number}",
f"S{receiver_base_port + 1}{channel.number}",
]
)
return names
def calculate_last_cycle_s_parameters(
raw_receiver_measurements_by_s_parameter: dict[str, np.ndarray],
def calculate_s_parameters(
raw_receiver_measurements_by_s_parameter: dict[tuple[int, int], np.ndarray],
master_reference_measurements_by_output: dict[int, np.ndarray],
) -> dict[str, np.ndarray]:
"""Convert raw receiver captures from the final sweep cycle into S-parameters.
) -> dict[tuple[int, int], np.ndarray]:
"""Convert raw receiver captures from the sweep cycle into S-parameters.
Points whose value never arrived (a dropped USB frame, still ``NaN``) or whose
incident reference is unusable (missing or a corrupt ~zero magnitude) cannot be
@@ -794,45 +693,40 @@ def calculate_last_cycle_s_parameters(
genuine fault rather than a stray dropped frame, and the cycle is rejected as
transient so the caller recovers instead of returning a mostly-zero trace.
"""
s_parameters: dict[str, np.ndarray] = {}
zero_filled_points_by_trace: dict[str, int] = {}
s_parameters: dict[tuple[int, int], np.ndarray] = {}
zero_filled_points_by_trace: dict[tuple[int, int], int] = {}
point_count = 0
for s_parameter_name, raw_receiver_measurements in raw_receiver_measurements_by_s_parameter.items():
# The trailing digit of every name is the 1-based OUTPUT number, which is what
# selects the incident reference (see build_forward_s_parameter_names).
output_number = int(s_parameter_name[-1])
master_reference_measurements = master_reference_measurements_by_output[output_number]
for combo, raw_receiver_measurements in raw_receiver_measurements_by_s_parameter.items():
master_reference_measurements = master_reference_measurements_by_output[combo[1]]
last_cycle_reference = master_reference_measurements[-1]
last_cycle_measurement = raw_receiver_measurements[-1]
point_count = last_cycle_measurement.size
point_count = raw_receiver_measurements.size
# Without a valid incident reference no trace at that point can be
# normalized; without this trace's receiver value there is nothing to
# normalize. Either way the point is unmeasurable and emitted as zero.
reference_unusable_mask = np.isnan(last_cycle_reference) | (
np.abs(last_cycle_reference) <= _MIN_REFERENCE_MAGNITUDE
reference_unusable_mask = ~np.isfinite(master_reference_measurements) | (
np.abs(master_reference_measurements) <= _MIN_REFERENCE_MAGNITUDE
)
measurement_missing_mask = np.isnan(last_cycle_measurement)
measurement_missing_mask = ~np.isfinite(raw_receiver_measurements)
zero_fill_mask = reference_unusable_mask | measurement_missing_mask
zero_filled_count = int(zero_fill_mask.sum())
if point_count and zero_filled_count > _MAX_ZERO_FILLED_POINTS_FRACTION * point_count:
raise TransientCollectionError(
f"Trace {s_parameter_name} is unmeasurable at {zero_filled_count}/{point_count} "
f"Trace {combo} is unmeasurable at {zero_filled_count}/{point_count} "
f"points (missing reference/measurement); rejecting cycle for recovery"
)
# Substitute neutral operands so the division never produces NaN/inf, then
# force the unmeasurable points to zero. Delivered points are untouched.
safe_reference = np.where(reference_unusable_mask, 1.0 + 0j, last_cycle_reference)
safe_measurement = np.where(measurement_missing_mask, 0.0 + 0j, last_cycle_measurement)
safe_reference = np.where(reference_unusable_mask, 1.0 + 0j, master_reference_measurements)
safe_measurement = np.where(measurement_missing_mask, 0.0 + 0j, raw_receiver_measurements)
s_parameter_values = safe_measurement / safe_reference
s_parameter_values[zero_fill_mask] = 0.0
if zero_filled_count:
zero_filled_points_by_trace[s_parameter_name] = zero_filled_count
s_parameters[s_parameter_name.lower()] = s_parameter_values
zero_filled_points_by_trace[combo] = zero_filled_count
s_parameters[combo] = s_parameter_values
if zero_filled_points_by_trace:
# One concise line per affected cycle (a rare dropped-frame event), so the
@@ -62,10 +62,6 @@ class OutputChannel:
stimulus_port: int
stage_index: int
@property
def number(self) -> int:
"""Return the 1-based output number used in S-parameter names."""
return self.index + 1
def build_output_channels(
@@ -129,7 +125,9 @@ class SweepConfiguration:
@dataclass(slots=True)
class SweepMeasurementResult:
"""Measured synchronized sweep for one active master output port."""
"""S21 traces keyed by zero-based (input, output) for one synchronized sweep."""
frequencies_hz: np.ndarray
s_parameters: dict[str, np.ndarray]
s_parameters: dict[tuple[int, int], np.ndarray]
capture_start_ns: int
capture_end_ns: int
+86 -17
View File
@@ -1,46 +1,115 @@
"""Native single-device LibreVNA service used by acquisition and device-limit queries."""
"""Single-device S21 acquisition over the LibreVNA USB protocol."""
from __future__ import annotations
from dataclasses import dataclass, field
from contextlib import suppress
import time
from python_app.hardware_full.librevna_backends import NativeLibreVnaBackend
from python_app.hardware_full.librevna_driver import LibreVNADevice, VNASweepSettings
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.models.run_config_model import RadarSweepModel
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_schema import RadarSweepModel
@dataclass(slots=True)
class LibreVnaService:
"""Provide the native single-device API; factories select mock acquisition."""
"""Native backend using direct USB LibreVNA Python driver."""
serial: str | None = None
strict_protocol_version: int = 14
backend_mode: str = "auto"
_backend: NativeLibreVnaBackend = field(init=False, repr=False)
def __post_init__(self) -> None:
if self.backend_mode.strip().lower() not in {"auto", "native"}:
raise ValueError(f"Unsupported native LibreVNA backend mode: {self.backend_mode}")
self._backend = NativeLibreVnaBackend(self.serial, self.strict_protocol_version)
_device: LibreVNADevice | None = field(init=False, default=None, repr=False)
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
def open(self) -> None:
self._backend.open()
"""Open device connection if it is not already open."""
if self._device is not None:
return
device = self._device = LibreVNADevice()
try:
device.connect(serial=self.serial, strict_protocol_version=self.strict_protocol_version, timeout_s=2.0)
except Exception:
with suppress(Exception):
self.close()
raise
@property
def is_open(self) -> bool:
"""Return `True` when native device connection is open."""
return self._device is not None
def close(self) -> None:
self._backend.close()
"""Close device connection when open."""
if self._device is None:
return
device, self._device = self._device, None
device.disconnect()
def configure(self, sweep: RadarSweepModel) -> None:
self._backend.configure(sweep)
"""Store and apply sweep settings to connected hardware."""
self._settings = sweep
if self._device is None:
return
settings = VNASweepSettings(
f_start_hz=sweep.start_hz,
f_stop_hz=sweep.stop_hz,
points=sweep.points,
if_bandwidth_hz=sweep.if_bandwidth_hz,
power_start_dbm=sweep.power_dbm,
power_stop_dbm=sweep.power_dbm,
dwell_s=sweep.dwell_us / 1_000_000,
)
self._device.vna.configure(settings)
def read_device_limits(self) -> dict[str, float | int]:
opened_here = not self._backend.is_open
"""Read frequency/IFBW/power/points limits from connected device."""
opened_here = not self.is_open
try:
if opened_here:
self.open()
return self._backend.read_device_limits()
assert self._device is not None
limits = self._device.get_device_info().limits
return {
"min_frequency_hz": float(limits.min_frequency_hz),
"max_frequency_hz": float(limits.max_frequency_hz),
"min_ifbw_hz": float(limits.min_ifbw_hz),
"max_ifbw_hz": float(limits.max_ifbw_hz),
"max_points": int(limits.max_points),
"min_power_dbm": float(limits.min_power_dbm),
"max_power_dbm": float(limits.max_power_dbm),
}
finally:
if opened_here:
self.close()
def prepare_combo(self, combo: ComboKey) -> None:
"""Standby acquisition starts a fresh sweep after the physical switch."""
def acquire(self) -> SweepResult:
return self._backend.acquire()
"""Acquire one sweep from hardware."""
if self._settings is None:
raise RuntimeError("Radar service is not configured")
if self._device is None:
raise RuntimeError("Device not found")
capture_start_ns = time.monotonic_ns()
result = self._device.vna.acquire()
result.capture_start_ns = capture_start_ns
result.capture_end_ns = time.monotonic_ns()
return result
def acquire_collection(self, collection_id: int = 1, *, combo: ComboKey | None = None) -> SweepCollection:
"""Expose one physical S21 path to the shared switch collector."""
result = self.acquire()
return SweepCollection(
collection_id=collection_id,
monotonic_ns=result.capture_end_ns,
traces=[TraceData(
ComboKey(0, 0), result.x, result.trace("s21"),
result.capture_start_ns, result.capture_end_ns,
)],
capture_start_ns=result.capture_start_ns,
capture_end_ns=result.capture_end_ns,
)
@@ -1,11 +1,11 @@
"""Factory for synchronized LibreVNA matrices and the shared physical mock."""
"""LibreVNA acquisition factory for single devices, matrices, and mock scenes."""
from __future__ import annotations
from typing import Protocol
from python_app.models.dataset_model import SweepCollection
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
from python_app.models.dataset_model import ComboKey, SweepCollection
from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel
class MatrixRadarService(Protocol):
@@ -20,37 +20,46 @@ class MatrixRadarService(Protocol):
def configure(self, sweep: RadarSweepModel) -> None:
"""Apply sweep settings to the radar."""
def acquire_collection(self, collection_id: int = 1) -> SweepCollection:
"""Acquire one complete virtual switch matrix in canonical combo order."""
def acquire_collection(self, collection_id: int = 1, *, combo: ComboKey | None = None) -> SweepCollection:
"""Acquire the matrix, or the smallest physical sweep containing combo."""
...
def recover(self) -> None:
"""Reconnect to the radar after a transient acquisition failure."""
def create_acquisition_service(config: RunConfigModel) -> MatrixRadarService:
"""Create a native matrix service or the shared single/multi mock scene."""
def create_matrix_radar_service(config: RunConfigModel) -> MatrixRadarService:
"""Create the configured LibreVNA collection source and its physical switches."""
if config.radar.driver_mode == "mock" and config.radar.model in {
RunConfigModel.LIBREVNA_MODEL, RunConfigModel.LIBREVNA_MULTI_MODEL,
}:
from python_app.hardware_full.point_target_mock_service import PointTargetMockService
return PointTargetMockService(config)
if config.radar.model == RunConfigModel.LIBREVNA_MODEL:
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.hardware_full.switch_service import SwitchService
from python_app.hardware_full.switched_matrix_radar_service import SwitchedMatrixRadarService
return SwitchedMatrixRadarService(
inner=LibreVnaService(serial=config.radar.serial or None),
output_switch=SwitchService.from_model(config.output_switch),
input_switch=SwitchService.from_model(config.input_switch),
inner_output_positions=1,
inner_input_positions=1,
settling_ms=config.runtime.settling_ms,
combos=[ComboKey(c.input, c.output) for c in (config.combos or config.build_runtime_combos())],
)
if config.radar.model != RunConfigModel.LIBREVNA_MULTI_MODEL:
raise RuntimeError("matrix radar service factory requires a matrix-mode radar.model")
from python_app.hardware_full.multi_device_service import MultiDeviceLibreVnaService
out_physical = config.matrix_output_switch_positions
in_physical = config.matrix_input_switch_positions
inner = MultiDeviceLibreVnaService(
master_serial=config.radar.serial,
slave_serials=list(config.radar.multi_device.slave_serials),
force_external_reference=config.radar.multi_device.force_external_reference,
recovery_attempts=config.radar.multi_device.recovery_attempts,
backend_mode=config.radar.driver_mode,
switch_substage_count=config.matrix_stimulus_switch_positions,
)
out_physical = config.matrix_output_switch_positions
in_physical = config.matrix_input_switch_positions
if out_physical <= 1 and in_physical <= 1:
return inner
@@ -67,6 +76,3 @@ def create_acquisition_service(config: RunConfigModel) -> MatrixRadarService:
inner_input_positions=RunConfigModel.MULTI_DEVICE_INPUT_POSITIONS,
settling_ms=config.runtime.settling_ms,
)
create_matrix_radar_service = create_acquisition_service
@@ -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
@@ -9,7 +9,7 @@ import numpy as np
from python_app.hardware_full.librevna_driver.models import SweepResult
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
_WAVE_SPEED_M_S = 3e8
_FRAME_PERIOD_S = 0.05
@@ -46,9 +46,6 @@ class PointTargetMockService:
def close(self) -> None:
"""Mock acquisition owns no hardware resources."""
def recover(self) -> None:
"""Mock acquisition has no hardware connection to recover."""
def read_device_limits(self) -> dict[str, float | int]:
raise RuntimeError("Mock radar has no hardware device limits")
@@ -56,7 +53,7 @@ class PointTargetMockService:
self._frequency_hz = np.linspace(sweep.start_hz, sweep.stop_hz, sweep.points, dtype=np.float32)
self._phase_per_m = -2j * np.pi * self._frequency_hz.astype(np.float64) / _WAVE_SPEED_M_S
def set_active_combo(self, combo: ComboKey) -> None:
def prepare_combo(self, combo: ComboKey) -> None:
if not (0 <= combo.input < self.config.input_switch.positions
and 0 <= combo.output < self.config.output_switch.positions):
raise ValueError(f"Mock combo is outside the antenna matrix: {combo}")
@@ -66,18 +63,14 @@ class PointTargetMockService:
collection = self._capture((self._active_combo,), 1)
trace = collection.traces[0]
return SweepResult(
x=trace.frequency_hz, traces={"s11": trace.s11, "s21": trace.s21},
x=trace.frequency_hz, traces={"s21": trace.s21},
capture_start_ns=trace.capture_start_ns, capture_end_ns=trace.capture_end_ns,
)
def acquire_collection(self, collection_id: int = 1) -> SweepCollection:
return self._capture(self._combos, collection_id)
def acquire_combo_collection(
self, *, input_pos: int, output_pos: int, collection_id: int = 1,
) -> SweepCollection:
self.set_active_combo(ComboKey(input_pos, output_pos))
return self._capture((self._active_combo,), collection_id)
def acquire_collection(self, collection_id: int = 1, *, combo: ComboKey | None = None) -> SweepCollection:
if combo is not None:
self.prepare_combo(combo)
return self._capture(self._combos if combo is None else (combo,), collection_id)
def _target_position(
self, sample_ns: int,
@@ -107,7 +100,6 @@ class PointTargetMockService:
r_rx = float(np.linalg.norm(target - rx.get(combo.input, (0.0, 0.0, 0.0))))
traces.append(TraceData(
combo=combo, frequency_hz=self._frequency_hz,
s11=np.asarray(0.5 / max(1.0, r_tx * r_tx) * np.exp(self._phase_per_m * 2 * r_tx), dtype=np.complex64),
s21=np.asarray(0.5 / max(1.0, r_tx * r_rx) * np.exp(self._phase_per_m * (r_tx + r_rx)), dtype=np.complex64),
capture_start_ns=capture_start_ns,
))
@@ -8,7 +8,8 @@ from typing import Protocol
from python_app.hardware_full.kamil_adc import KamilAdcService
from python_app.hardware_full.librevna_driver.models import SweepResult
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.models.run_config_model import RadarSweepModel, RunConfigModel
from python_app.models.dataset_model import ComboKey
from python_app.models.run_config_schema import RadarSweepModel, RunConfigModel
logger = logging.getLogger(__name__)
@@ -29,6 +30,9 @@ class SingleRadarService(Protocol):
"""Read device capability limits."""
...
def prepare_combo(self, combo: ComboKey) -> None:
"""Select the simulated path or discard buffered data after switching."""
def acquire(self) -> SweepResult:
"""Acquire one sweep."""
...
@@ -36,7 +40,7 @@ class SingleRadarService(Protocol):
def create_single_radar_service(config: RunConfigModel) -> SingleRadarService:
"""Create the Python service for a non-matrix-radar config."""
if config.is_matrix_radar:
if config.is_multi_device:
raise RuntimeError(
"single-radar service factory does not support matrix radars; "
"use create_matrix_radar_service instead"
@@ -49,7 +53,7 @@ def create_single_radar_service(config: RunConfigModel) -> SingleRadarService:
from python_app.hardware_full.point_target_mock_service import PointTargetMockService
return PointTargetMockService(config)
return LibreVnaService(serial=config.radar.serial or None, backend_mode=config.radar.driver_mode)
return LibreVnaService(serial=config.radar.serial or None)
if model == RunConfigModel.KAMIL_ADC_MODEL:
if config.radar.driver_mode != "native":
@@ -1,13 +1,11 @@
"""Switch driver implementations for native GPIO and mock operation."""
from python_app.hardware_full.switch_drivers.h7992_driver import H7992Driver
from python_app.hardware_full.switch_drivers.hmc349a_driver import HMC349ADriver
from python_app.hardware_full.switch_drivers.interface import SwitchDriverProtocol
from python_app.hardware_full.switch_drivers.mock_driver import MockSwitchDriver
__all__ = [
"H7992Driver",
"HMC349ADriver",
"MockSwitchDriver",
"SwitchDriverProtocol",
]
@@ -1,84 +0,0 @@
"""Native GPIO driver for HMC349A switch."""
from __future__ import annotations
from dataclasses import dataclass, field
from contextlib import suppress
from python_app.hardware_full.switch_drivers.gpio_uapi import GpioOutputLines
@dataclass(slots=True)
class HMC349ADriver:
"""Drive HMC349A using one control GPIO line."""
name: str
positions: int = 2
default_position: int = 0
gpio_chip: str = "/dev/gpiochip0"
pin_a: int = 17
invert_logic: bool = False
_lines: GpioOutputLines | None = field(init=False, default=None, repr=False)
_is_open: bool = field(init=False, default=False, repr=False)
_current_position: int = field(init=False, default=0, repr=False)
def open(self) -> None:
"""Open GPIO lines and switch to default position."""
if self._is_open:
return
self._validate()
offsets = [self.pin_a]
self._lines = GpioOutputLines(
chip=self.gpio_chip,
offsets=offsets,
consumer=f"radar_{self.name}",
)
try:
self._lines.open()
self._is_open = True
self.switch_to(self.default_position)
except Exception:
with suppress(Exception):
self.close()
raise
def close(self) -> None:
"""Close GPIO lines."""
lines, self._lines = self._lines, None
self._is_open = False
if lines is not None:
lines.close()
def position_count(self) -> int:
"""Return number of supported positions."""
return self.positions
def switch_to(self, position: int) -> None:
"""Switch hardware to selected position."""
if not self._is_open or self._lines is None:
raise RuntimeError(f"Switch driver is not open for {self.name}")
if position < 0 or position >= self.positions:
raise ValueError(f"Position out of range for {self.name}: {position}")
control = int(position & 0x01)
if self.invert_logic:
control ^= 0x01
self._lines.set_values([control])
self._current_position = int(position)
@property
def current_position(self) -> int:
"""Return current position."""
return self._current_position
def _validate(self) -> None:
"""Validate HMC349A driver parameters."""
if self.positions <= 0 or self.positions > 2:
raise ValueError(f"HMC349A positions must be in range [1,2] for {self.name}")
if self.default_position < 0 or self.default_position >= self.positions:
raise ValueError(f"default_position out of range for {self.name}")
if self.pin_a < 0:
raise ValueError(f"pin_a is invalid for {self.name}")
@@ -16,6 +16,7 @@ class SwitchDriverProtocol(Protocol):
def position_count(self) -> int:
"""Return total number of supported positions."""
...
def switch_to(self, position: int) -> None:
"""Switch to requested zero-based position."""
@@ -23,3 +24,4 @@ class SwitchDriverProtocol(Protocol):
@property
def current_position(self) -> int:
"""Return currently active position."""
...
+21 -25
View File
@@ -3,11 +3,12 @@
from __future__ import annotations
from dataclasses import dataclass, field
from collections.abc import Callable
import time
from typing import TYPE_CHECKING
from python_app.hardware_full.switch_drivers import (
H7992Driver,
HMC349ADriver,
MockSwitchDriver,
SwitchDriverProtocol,
)
@@ -23,11 +24,9 @@ class SwitchService:
name: str
positions: int
mode: str = "mock"
driver: str = "h7992"
gpio_chip: str = "/dev/gpiochip0"
pin_a: int = 17
pin_b: int = 27
invert_logic: bool = False
default_position: int = 0
_driver: SwitchDriverProtocol = field(init=False, repr=False)
@@ -46,11 +45,9 @@ class SwitchService:
name=model.name,
positions=model.positions,
mode=model.driver_mode,
driver=model.driver,
gpio_chip=model.gpio_chip,
pin_a=model.pin_a,
pin_b=model.pin_b,
invert_logic=model.invert_logic,
default_position=model.default_position,
)
@@ -78,7 +75,6 @@ class SwitchService:
def _build_driver(self) -> SwitchDriverProtocol:
"""Instantiate concrete driver according to configured mode and driver kind."""
mode = self.mode.strip().lower()
driver_kind = self.driver.strip().lower()
if mode == "mock":
return MockSwitchDriver(
@@ -90,23 +86,23 @@ class SwitchService:
if mode != "native":
raise RuntimeError(f"Unsupported switch mode: {self.mode}")
if driver_kind == "h7992":
return H7992Driver(
name=self.name,
positions=self.positions,
default_position=self.default_position,
gpio_chip=self.gpio_chip,
pin_a=self.pin_a,
pin_b=self.pin_b,
)
if driver_kind == "hmc349a":
return HMC349ADriver(
name=self.name,
positions=self.positions,
default_position=self.default_position,
gpio_chip=self.gpio_chip,
pin_a=self.pin_a,
invert_logic=self.invert_logic,
)
return H7992Driver(
name=self.name,
positions=self.positions,
default_position=self.default_position,
gpio_chip=self.gpio_chip,
pin_a=self.pin_a,
pin_b=self.pin_b,
)
raise RuntimeError(f"Unsupported switch driver: {self.driver}")
def select_and_settle(
output_switch: SwitchDriverProtocol | None, input_switch: SwitchDriverProtocol | None,
output: int, input: int, settling_ms: int, *, wait: Callable[[float], object] = time.sleep,
) -> bool:
"""Drive both switches and settle; return False when an interruptible wait stops."""
if output_switch is not None:
output_switch.switch_to(output)
if input_switch is not None:
input_switch.switch_to(input)
return not (settling_ms > 0 and wait(settling_ms / 1000.0))
@@ -7,9 +7,10 @@ import logging
import time
from python_app.hardware_full.matrix_radar_service import MatrixRadarService
from python_app.hardware_full.switch_service import SwitchService
from python_app.hardware_full.switch_service import SwitchService, select_and_settle
from python_app.hardware_full.switch_drivers.interface import SwitchDriverProtocol
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import RadarSweepModel, SwitchModel
from python_app.models.run_config_schema import RadarSweepModel, SwitchModel
logger = logging.getLogger(__name__)
@@ -20,17 +21,17 @@ class SwitchedMatrixRadarService:
Implements the ``MatrixRadarService`` protocol, so the producer and the
capture workflows treat it as an ordinary matrix radar that simply reports
more positions. The hardware sweep is never stopped: switches are only ever
driven BETWEEN ``acquire_collection`` calls, and the inner service's
free-running collection discards any partially swept cycle.
more positions. Switches settle before each inner acquisition. A free-running
matrix discards the partial cycle; a single device starts one standby sweep.
"""
inner: MatrixRadarService
output_switch: SwitchService | None
input_switch: SwitchService | None
output_switch: SwitchDriverProtocol | None
input_switch: SwitchDriverProtocol | None
inner_output_positions: int
inner_input_positions: int
settling_ms: int = 0
combos: list[ComboKey] | None = None
# Monotonic end of the previous inner collection, so the DEBUG timing trace can
# report how long the gap between "sweep collected" and "switch driven" really is
# — that gap is where a stale in-flight point 0 can still slip past the drain.
@@ -61,85 +62,50 @@ class SwitchedMatrixRadarService:
"""Apply sweep settings to the inner radar."""
self.inner.configure(sweep)
def recover(self) -> None:
"""Reconnect the inner radar; switches are not on the USB transport."""
self.inner.recover()
def acquire_collection(self, collection_id: int = 1) -> SweepCollection:
"""Acquire the full widened matrix, one inner collection per switch step.
def acquire_collection(self, collection_id: int = 1, *, combo: ComboKey | None = None) -> SweepCollection:
"""Acquire the matrix, or only the physical switch step containing combo.
A partial failure raises instead of returning a short collection: the
preprocessor requires every runtime combo to be present, so half a matrix
is worse than a dropped frame.
"""
capture_start_ns = time.monotonic_ns()
out_steps = self.output_switch.position_count() if self.output_switch is not None else 1
in_steps = self.input_switch.position_count() if self.input_switch is not None else 1
total_inputs = in_steps * self.inner_input_positions
total_outputs = out_steps * self.inner_output_positions
total_inputs, total_outputs = self._shape()
# Place each trace at its canonical index rather than appending. The GPR stage
# rejects a collection whose trace order differs from run.combos, and run.combos
# is built output-major (`build_full_combos`) while these loops run switch-major.
# Appending happens to agree for an output switch and to disagree for an input one.
slots: list[TraceData | None] = [None] * (total_inputs * total_outputs)
for out_k in range(out_steps):
for in_k in range(in_steps):
for trace in self._acquire_step_traces(out_k, in_k, collection_id):
slots[trace.combo.output * total_inputs + trace.combo.input] = trace
if any(trace is None for trace in slots):
missing = sum(1 for trace in slots if trace is None)
raise RuntimeError(
f"Switched matrix collection is incomplete: {missing} of {len(slots)} combos missing"
)
combos = [combo] if combo is not None else self.combos
if combos is None:
combos = [
ComboKey(input_pos, output_pos)
for output_pos in range(total_outputs)
for input_pos in range(total_inputs)
]
if any(not (0 <= c.input < total_inputs and 0 <= c.output < total_outputs) for c in combos):
raise ValueError("Requested combo is outside the switched matrix")
steps = dict.fromkeys(
(c.output // self.inner_output_positions, c.input // self.inner_input_positions)
for c in combos
)
traces = {
trace.combo: trace
for out_k, in_k in steps
for trace in self._acquire_step_traces(out_k, in_k, collection_id)
}
missing = set(combos) - traces.keys()
if missing:
raise RuntimeError(f"Switched matrix collection is incomplete: {len(missing)} combos missing")
return SweepCollection(
collection_id=int(collection_id),
monotonic_ns=time.monotonic_ns(),
traces=[trace for trace in slots if trace is not None],
traces=list(traces.values()) if combo is not None else [traces[key] for key in combos],
capture_start_ns=capture_start_ns,
capture_end_ns=time.monotonic_ns(),
)
def acquire_combo_collection(
self,
*,
input_pos: int,
output_pos: int,
collection_id: int = 1,
) -> SweepCollection:
"""Acquire only the physical switch step that carries one widened combo.
The per-combo capture workflows need a single trace at a time; sweeping
every switch position for that (a full ``acquire_collection``) multiplies
the capture time by the number of physical steps and freezes the caller
for the whole sweep. One widened combo lives entirely inside one
(out_k, in_k) step, so acquiring just that step is sufficient. The result
contains that step's traces with widened combo keys, including the
requested combo.
"""
out_steps = self.output_switch.position_count() if self.output_switch is not None else 1
in_steps = self.input_switch.position_count() if self.input_switch is not None else 1
total_inputs = in_steps * self.inner_input_positions
total_outputs = out_steps * self.inner_output_positions
if not (0 <= int(input_pos) < total_inputs and 0 <= int(output_pos) < total_outputs):
raise ValueError(
f"Widened combo out of range: input={input_pos} (of {total_inputs}), "
f"output={output_pos} (of {total_outputs})"
)
capture_start_ns = time.monotonic_ns()
out_k = int(output_pos) // self.inner_output_positions
in_k = int(input_pos) // self.inner_input_positions
traces = self._acquire_step_traces(out_k, in_k, collection_id)
return SweepCollection(
collection_id=int(collection_id),
monotonic_ns=time.monotonic_ns(),
traces=traces,
capture_start_ns=capture_start_ns,
capture_end_ns=time.monotonic_ns(),
def _shape(self) -> tuple[int, int]:
return (
self.inner_input_positions * (self.input_switch.position_count() if self.input_switch else 1),
self.inner_output_positions * (self.output_switch.position_count() if self.output_switch else 1),
)
def _acquire_step_traces(self, out_k: int, in_k: int, collection_id: int) -> list[TraceData]:
@@ -151,22 +117,14 @@ class SwitchedMatrixRadarService:
ended. The switch drive and settling are deliberately outside the window.
"""
step_start_ns = time.monotonic_ns()
if self.output_switch is not None:
self.output_switch.switch_to(out_k)
if self.input_switch is not None:
self.input_switch.switch_to(in_k)
switched_ns = time.monotonic_ns()
# Settle AFTER the last switch change and BEFORE collecting, so the
# cycle we anchor on starts with the RF path already stable.
if self.settling_ms > 0:
time.sleep(self.settling_ms / 1000.0)
select_and_settle(self.output_switch, self.input_switch, out_k, in_k, self.settling_ms)
settled_ns = time.monotonic_ns()
sub = self.inner.acquire_collection(collection_id)
inner_end_ns = time.monotonic_ns()
logger.debug(
"timing: collection %d step out=%d in=%d | gap_prev_collect_to_switch=%s ms, "
"switch=%.3f ms, settle=%.2f ms, inner_collect=%.2f ms",
"switch_and_settle=%.2f ms, inner_collect=%.2f ms",
collection_id,
out_k,
in_k,
@@ -175,8 +133,7 @@ class SwitchedMatrixRadarService:
if self._last_inner_end_ns
else "n/a"
),
(switched_ns - step_start_ns) / 1e6,
(settled_ns - switched_ns) / 1e6,
(settled_ns - step_start_ns) / 1e6,
(inner_end_ns - settled_ns) / 1e6,
)
self._last_inner_end_ns = inner_end_ns