Rework radar system
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
...
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user