added generator mode
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
"""Standalone LibreVNA signal-generator sweep support."""
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GeneratorSweepConfig",
|
||||||
|
"GeneratorSweepRunner",
|
||||||
|
"HardwarePwmGate",
|
||||||
|
"ResolvedGeneratorSweepConfig",
|
||||||
|
"resolve_generator_sweep_config",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> object:
|
||||||
|
"""Load generator sweep helpers lazily to avoid heavy hardware imports."""
|
||||||
|
if name in {"GeneratorSweepConfig", "ResolvedGeneratorSweepConfig", "resolve_generator_sweep_config"}:
|
||||||
|
from .config import GeneratorSweepConfig, ResolvedGeneratorSweepConfig, resolve_generator_sweep_config
|
||||||
|
|
||||||
|
exports = {
|
||||||
|
"GeneratorSweepConfig": GeneratorSweepConfig,
|
||||||
|
"ResolvedGeneratorSweepConfig": ResolvedGeneratorSweepConfig,
|
||||||
|
"resolve_generator_sweep_config": resolve_generator_sweep_config,
|
||||||
|
}
|
||||||
|
return exports[name]
|
||||||
|
if name == "HardwarePwmGate":
|
||||||
|
from .pwm import HardwarePwmGate
|
||||||
|
|
||||||
|
return HardwarePwmGate
|
||||||
|
if name == "GeneratorSweepRunner":
|
||||||
|
from .runner import GeneratorSweepRunner
|
||||||
|
|
||||||
|
return GeneratorSweepRunner
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""Configuration resolution for the standalone generator sweep."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class GeneratorSweepConfig:
|
||||||
|
"""User-edited generator sweep configuration stored next to the script."""
|
||||||
|
|
||||||
|
log_level: str = "INFO"
|
||||||
|
serial: str | None = None
|
||||||
|
strict_protocol_version: int = 14
|
||||||
|
start_hz: float = 1_000_000.0
|
||||||
|
stop_hz: float = 6_000_000.0
|
||||||
|
step_hz: float = 0.0
|
||||||
|
points: int = 0
|
||||||
|
hold_time_ms: float = 50.0
|
||||||
|
loop: bool = True
|
||||||
|
port: int = 1
|
||||||
|
power_dbm: float = -10.0
|
||||||
|
amplitude_correction: bool = False
|
||||||
|
settle_timeout_ms: int = 1_000
|
||||||
|
status_poll_ms: float = 10.0
|
||||||
|
post_lock_delay_us: int = 0
|
||||||
|
gpio_chip: str = "/dev/gpiochip0"
|
||||||
|
sweep_start_pin: int = 5
|
||||||
|
curr_step_pin: int = 6
|
||||||
|
curr_step_initial_level: int = 0
|
||||||
|
pwm_pin: int = 12
|
||||||
|
pwm_frequency_hz: int = 2_000_000
|
||||||
|
pwm_duty_cycle: float = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True, frozen=True)
|
||||||
|
class ResolvedGeneratorSweepConfig:
|
||||||
|
"""Fully validated generator sweep configuration."""
|
||||||
|
|
||||||
|
log_level: int
|
||||||
|
serial: str | None
|
||||||
|
strict_protocol_version: int
|
||||||
|
frequencies_hz: tuple[int, ...]
|
||||||
|
hold_time_s: float
|
||||||
|
loop: bool
|
||||||
|
port: int
|
||||||
|
power_dbm: float
|
||||||
|
amplitude_correction: bool
|
||||||
|
settle_timeout_s: float
|
||||||
|
status_poll_s: float
|
||||||
|
post_lock_delay_s: float
|
||||||
|
gpio_chip: str
|
||||||
|
sweep_start_pin: int
|
||||||
|
curr_step_pin: int
|
||||||
|
curr_step_initial_level: int
|
||||||
|
pwm_pin: int
|
||||||
|
pwm_frequency_hz: int
|
||||||
|
pwm_duty_cycle: float
|
||||||
|
|
||||||
|
|
||||||
|
def _require(condition: bool, message: str) -> None:
|
||||||
|
"""Raise ``ValueError`` when a configuration invariant is violated."""
|
||||||
|
if not condition:
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_positive_int(value: int, label: str) -> None:
|
||||||
|
"""Validate positive integer setting."""
|
||||||
|
_require(value > 0, f"{label} must be > 0")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_non_negative_int(value: int, label: str) -> None:
|
||||||
|
"""Validate non-negative integer setting."""
|
||||||
|
_require(value >= 0, f"{label} must be >= 0")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_non_negative_float(value: float, label: str) -> None:
|
||||||
|
"""Validate non-negative float setting."""
|
||||||
|
_require(value >= 0.0, f"{label} must be >= 0")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_positive_float(value: float, label: str) -> None:
|
||||||
|
"""Validate positive float setting."""
|
||||||
|
_require(value > 0.0, f"{label} must be > 0")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_log_level(value: str) -> int:
|
||||||
|
"""Normalize logging level name to ``logging`` module integer constant."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
normalized = value.strip().upper()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("generator_sweep.log_level must not be empty")
|
||||||
|
|
||||||
|
level = logging.getLevelName(normalized)
|
||||||
|
if isinstance(level, int):
|
||||||
|
return level
|
||||||
|
raise ValueError("generator_sweep.log_level must be one of DEBUG, INFO, WARNING, ERROR, CRITICAL")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_frequency_grid(model: GeneratorSweepConfig) -> tuple[int, ...]:
|
||||||
|
"""Build the ordered frequency grid with inclusive stop frequency."""
|
||||||
|
start_hz = int(round(model.start_hz))
|
||||||
|
stop_hz = int(round(model.stop_hz))
|
||||||
|
step_hz = int(round(model.step_hz))
|
||||||
|
points = int(model.points)
|
||||||
|
|
||||||
|
_validate_positive_int(start_hz, "generator_sweep.start_hz")
|
||||||
|
_validate_positive_int(stop_hz, "generator_sweep.stop_hz")
|
||||||
|
_require(stop_hz >= start_hz, "generator_sweep.stop_hz must be >= generator_sweep.start_hz")
|
||||||
|
|
||||||
|
has_step = step_hz > 0
|
||||||
|
has_points = points > 0
|
||||||
|
_require(has_step != has_points, "generator_sweep must define exactly one of step_hz or points")
|
||||||
|
|
||||||
|
if start_hz == stop_hz:
|
||||||
|
if has_points:
|
||||||
|
_require(points == 1, "generator_sweep.points must be 1 when start_hz == stop_hz")
|
||||||
|
return (start_hz,)
|
||||||
|
|
||||||
|
if has_step:
|
||||||
|
_validate_positive_int(step_hz, "generator_sweep.step_hz")
|
||||||
|
frequencies: list[int] = []
|
||||||
|
current_hz = start_hz
|
||||||
|
while current_hz < stop_hz:
|
||||||
|
frequencies.append(current_hz)
|
||||||
|
current_hz += step_hz
|
||||||
|
if not frequencies or frequencies[-1] != stop_hz:
|
||||||
|
frequencies.append(stop_hz)
|
||||||
|
return tuple(frequencies)
|
||||||
|
|
||||||
|
_validate_positive_int(points, "generator_sweep.points")
|
||||||
|
_require(points >= 2, "generator_sweep.points must be >= 2 when start_hz != stop_hz")
|
||||||
|
span_hz = stop_hz - start_hz
|
||||||
|
frequencies = tuple(
|
||||||
|
int(round(start_hz + (span_hz * index) / float(points - 1)))
|
||||||
|
for index in range(points)
|
||||||
|
)
|
||||||
|
_require(frequencies[0] == start_hz, "generator_sweep.points grid does not start at start_hz")
|
||||||
|
_require(frequencies[-1] == stop_hz, "generator_sweep.points grid does not end at stop_hz")
|
||||||
|
_require(
|
||||||
|
all(left < right for left, right in zip(frequencies, frequencies[1:])),
|
||||||
|
"generator sweep grid contains duplicate or non-monotonic frequencies",
|
||||||
|
)
|
||||||
|
return frequencies
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_generator_sweep_config(model: GeneratorSweepConfig) -> ResolvedGeneratorSweepConfig:
|
||||||
|
"""Validate and resolve runtime configuration for generator sweep mode."""
|
||||||
|
log_level = _resolve_log_level(model.log_level)
|
||||||
|
frequencies_hz = _build_frequency_grid(model)
|
||||||
|
hold_time_ms = float(model.hold_time_ms)
|
||||||
|
settle_timeout_ms = int(model.settle_timeout_ms)
|
||||||
|
status_poll_ms = float(model.status_poll_ms)
|
||||||
|
post_lock_delay_us = int(model.post_lock_delay_us)
|
||||||
|
pwm_frequency_hz = int(model.pwm_frequency_hz)
|
||||||
|
sweep_start_pin = int(model.sweep_start_pin)
|
||||||
|
curr_step_pin = int(model.curr_step_pin)
|
||||||
|
pwm_pin = int(model.pwm_pin)
|
||||||
|
curr_step_initial_level = int(model.curr_step_initial_level)
|
||||||
|
|
||||||
|
_validate_non_negative_float(hold_time_ms, "generator_sweep.hold_time_ms")
|
||||||
|
_validate_positive_int(settle_timeout_ms, "generator_sweep.settle_timeout_ms")
|
||||||
|
_validate_positive_float(status_poll_ms, "generator_sweep.status_poll_ms")
|
||||||
|
_validate_non_negative_int(post_lock_delay_us, "generator_sweep.post_lock_delay_us")
|
||||||
|
_validate_positive_int(pwm_frequency_hz, "generator_sweep.pwm_frequency_hz")
|
||||||
|
_validate_positive_int(int(model.strict_protocol_version), "generator_sweep.strict_protocol_version")
|
||||||
|
_require(model.port in {1, 2}, "generator_sweep.port must be 1 or 2")
|
||||||
|
_require(curr_step_initial_level in {0, 1}, "generator_sweep.curr_step_initial_level must be 0 or 1")
|
||||||
|
_require(bool(model.gpio_chip.strip()), "generator_sweep.gpio_chip must not be empty")
|
||||||
|
_validate_non_negative_int(sweep_start_pin, "generator_sweep.sweep_start_pin")
|
||||||
|
_validate_non_negative_int(curr_step_pin, "generator_sweep.curr_step_pin")
|
||||||
|
_validate_non_negative_int(pwm_pin, "generator_sweep.pwm_pin")
|
||||||
|
_require(
|
||||||
|
len({sweep_start_pin, curr_step_pin, pwm_pin}) == 3,
|
||||||
|
"generator_sweep sweep_start_pin, curr_step_pin, and pwm_pin must be distinct",
|
||||||
|
)
|
||||||
|
_require(
|
||||||
|
0.0 < float(model.pwm_duty_cycle) <= 1.0,
|
||||||
|
"generator_sweep.pwm_duty_cycle must be within (0, 1]",
|
||||||
|
)
|
||||||
|
|
||||||
|
return ResolvedGeneratorSweepConfig(
|
||||||
|
log_level=log_level,
|
||||||
|
serial=(model.serial.strip() or None) if isinstance(model.serial, str) else model.serial,
|
||||||
|
strict_protocol_version=int(model.strict_protocol_version),
|
||||||
|
frequencies_hz=frequencies_hz,
|
||||||
|
hold_time_s=hold_time_ms / 1000.0,
|
||||||
|
loop=bool(model.loop),
|
||||||
|
port=int(model.port),
|
||||||
|
power_dbm=float(model.power_dbm),
|
||||||
|
amplitude_correction=bool(model.amplitude_correction),
|
||||||
|
settle_timeout_s=settle_timeout_ms / 1000.0,
|
||||||
|
status_poll_s=status_poll_ms / 1000.0,
|
||||||
|
post_lock_delay_s=post_lock_delay_us / 1_000_000.0,
|
||||||
|
gpio_chip=model.gpio_chip.strip(),
|
||||||
|
sweep_start_pin=sweep_start_pin,
|
||||||
|
curr_step_pin=curr_step_pin,
|
||||||
|
curr_step_initial_level=curr_step_initial_level,
|
||||||
|
pwm_pin=pwm_pin,
|
||||||
|
pwm_frequency_hz=pwm_frequency_hz,
|
||||||
|
pwm_duty_cycle=float(model.pwm_duty_cycle),
|
||||||
|
)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Hardware-PWM backend for Raspberry Pi 5 generator sweep gating."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_PWM_CHANNEL_BY_PIN = {
|
||||||
|
12: 0,
|
||||||
|
13: 1,
|
||||||
|
18: 2,
|
||||||
|
19: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class HardwarePwmGate:
|
||||||
|
"""Thin wrapper around Raspberry Pi hardware PWM output."""
|
||||||
|
|
||||||
|
pin: int
|
||||||
|
frequency_hz: int
|
||||||
|
duty_cycle: float
|
||||||
|
_pwm: object | None = field(init=False, default=None, repr=False)
|
||||||
|
_running: bool = field(init=False, default=False, repr=False)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate static PWM configuration."""
|
||||||
|
if self.pin not in _PWM_CHANNEL_BY_PIN:
|
||||||
|
raise ValueError(
|
||||||
|
"generator_sweep.pwm_pin must be one of GPIO12, GPIO13, GPIO18, or GPIO19 on Raspberry Pi 5"
|
||||||
|
)
|
||||||
|
if self.frequency_hz <= 0:
|
||||||
|
raise ValueError("generator_sweep.pwm_frequency_hz must be > 0")
|
||||||
|
if not (0.0 < self.duty_cycle <= 1.0):
|
||||||
|
raise ValueError("generator_sweep.pwm_duty_cycle must be within (0, 1]")
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
"""Initialize hardware PWM handle."""
|
||||||
|
if self._pwm is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from rpi_hardware_pwm import HardwarePWM
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"rpi_hardware_pwm is required for generator_sweep PWM output. "
|
||||||
|
"Install project dependencies on the Raspberry Pi before running this script."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
channel = _PWM_CHANNEL_BY_PIN[self.pin]
|
||||||
|
try:
|
||||||
|
self._pwm = HardwarePWM(pwm_channel=channel, hz=self.frequency_hz, chip=0)
|
||||||
|
except Exception as exc:
|
||||||
|
config_hint = _boot_config_hint()
|
||||||
|
raise RuntimeError(
|
||||||
|
"Failed to initialize Raspberry Pi hardware PWM. "
|
||||||
|
f"Enable the PWM overlay in {config_hint} by adding "
|
||||||
|
"'dtoverlay=pwm-2chan', then reboot the Raspberry Pi."
|
||||||
|
) from exc
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def enable(self) -> None:
|
||||||
|
"""Start PWM output when not already running."""
|
||||||
|
if self._pwm is None:
|
||||||
|
raise RuntimeError("Hardware PWM gate is not open")
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
duty_percent = self.duty_cycle * 100.0
|
||||||
|
self._pwm.start(duty_percent)
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
def disable(self) -> None:
|
||||||
|
"""Stop PWM output when active."""
|
||||||
|
if self._pwm is None or not self._running:
|
||||||
|
return
|
||||||
|
self._pwm.stop()
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Stop PWM and release runtime state."""
|
||||||
|
self.disable()
|
||||||
|
self._pwm = None
|
||||||
|
|
||||||
|
|
||||||
|
def _boot_config_hint() -> str:
|
||||||
|
"""Return the most likely active Raspberry Pi boot config path."""
|
||||||
|
firmware_config = Path("/boot/firmware/config.txt")
|
||||||
|
if firmware_config.exists():
|
||||||
|
return str(firmware_config)
|
||||||
|
return "/boot/config.txt"
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Orchestration for standalone LibreVNA signal-generator sweeps."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from python_app.generator_sweep.config import (
|
||||||
|
GeneratorSweepConfig,
|
||||||
|
ResolvedGeneratorSweepConfig,
|
||||||
|
resolve_generator_sweep_config,
|
||||||
|
)
|
||||||
|
from python_app.generator_sweep.pwm import HardwarePwmGate
|
||||||
|
from python_app.hardware_full.librevna_driver import GeneratorSettings, LibreVNADevice
|
||||||
|
from python_app.hardware_full.switch_drivers.gpio_uapi import GpioOutputLines
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _MarkerOutputs:
|
||||||
|
"""Own two GPIO marker signals used during generator sweeps."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
gpio_chip: str,
|
||||||
|
sweep_start_pin: int,
|
||||||
|
curr_step_pin: int,
|
||||||
|
curr_step_initial_level: int,
|
||||||
|
) -> None:
|
||||||
|
"""Store GPIO metadata and initialize marker state."""
|
||||||
|
self._lines = GpioOutputLines(
|
||||||
|
chip=gpio_chip,
|
||||||
|
offsets=[sweep_start_pin, curr_step_pin],
|
||||||
|
consumer="generator_sweep",
|
||||||
|
)
|
||||||
|
self._curr_step_initial_level = curr_step_initial_level
|
||||||
|
self._sweep_start_level = 0
|
||||||
|
self._curr_step_level = curr_step_initial_level
|
||||||
|
self._is_open = False
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
"""Request GPIO lines and apply idle levels."""
|
||||||
|
self._lines.open()
|
||||||
|
self._is_open = True
|
||||||
|
self.reset_to_idle()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Release GPIO lines."""
|
||||||
|
self._is_open = False
|
||||||
|
self._lines.close()
|
||||||
|
|
||||||
|
def begin_sweep(self) -> None:
|
||||||
|
"""Raise sweep marker while preserving current step level."""
|
||||||
|
time.sleep(0.001)
|
||||||
|
self._sweep_start_level = 1
|
||||||
|
self._apply()
|
||||||
|
|
||||||
|
def end_sweep(self) -> None:
|
||||||
|
"""Drop sweep marker after a completed pass."""
|
||||||
|
self._sweep_start_level = 0
|
||||||
|
self._apply()
|
||||||
|
|
||||||
|
def toggle_step(self) -> None:
|
||||||
|
"""Invert current-step marker."""
|
||||||
|
self._curr_step_level = 1 - self._curr_step_level
|
||||||
|
self._apply()
|
||||||
|
|
||||||
|
def reset_to_idle(self) -> None:
|
||||||
|
"""Return both markers to their configured idle state."""
|
||||||
|
self._sweep_start_level = 0
|
||||||
|
self._curr_step_level = self._curr_step_initial_level
|
||||||
|
self._apply()
|
||||||
|
|
||||||
|
def _apply(self) -> None:
|
||||||
|
"""Write current marker levels to GPIO."""
|
||||||
|
if not self._is_open:
|
||||||
|
return
|
||||||
|
self._lines.set_values([self._sweep_start_level, self._curr_step_level])
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratorSweepRunner:
|
||||||
|
"""Execute a signal-generator frequency sweep defined in run config."""
|
||||||
|
|
||||||
|
def __init__(self, config: GeneratorSweepConfig) -> None:
|
||||||
|
"""Resolve configuration and prepare runtime helpers."""
|
||||||
|
self._config: ResolvedGeneratorSweepConfig = resolve_generator_sweep_config(config)
|
||||||
|
self._device: LibreVNADevice | None = None
|
||||||
|
self._markers = _MarkerOutputs(
|
||||||
|
gpio_chip=self._config.gpio_chip,
|
||||||
|
sweep_start_pin=self._config.sweep_start_pin,
|
||||||
|
curr_step_pin=self._config.curr_step_pin,
|
||||||
|
curr_step_initial_level=self._config.curr_step_initial_level,
|
||||||
|
)
|
||||||
|
self._pwm = HardwarePwmGate(
|
||||||
|
pin=self._config.pwm_pin,
|
||||||
|
frequency_hz=self._config.pwm_frequency_hz,
|
||||||
|
duty_cycle=self._config.pwm_duty_cycle,
|
||||||
|
)
|
||||||
|
self._base_generator_settings = GeneratorSettings(
|
||||||
|
frequency_hz=float(self._config.frequencies_hz[0]),
|
||||||
|
power_dbm=self._config.power_dbm,
|
||||||
|
active_port=self._config.port,
|
||||||
|
apply_amplitude_correction=self._config.amplitude_correction,
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Open resources and execute one-shot or looping sweeps."""
|
||||||
|
try:
|
||||||
|
self._open()
|
||||||
|
while True:
|
||||||
|
self._run_single_sweep()
|
||||||
|
if not self._config.loop:
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Generator sweep interrupted by user")
|
||||||
|
finally:
|
||||||
|
self._close()
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
"""Open GPIO, PWM, and LibreVNA device connection."""
|
||||||
|
self._markers.open()
|
||||||
|
self._pwm.open()
|
||||||
|
self._device = LibreVNADevice()
|
||||||
|
self._device.connect(
|
||||||
|
serial=self._config.serial,
|
||||||
|
strict_protocol_version=self._config.strict_protocol_version,
|
||||||
|
timeout_s=2.0,
|
||||||
|
)
|
||||||
|
self._device.generator.set_idle(timeout_s=1.0)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Generator sweep ready: points=%d start=%dHz stop=%dHz loop=%s",
|
||||||
|
len(self._config.frequencies_hz),
|
||||||
|
self._config.frequencies_hz[0],
|
||||||
|
self._config.frequencies_hz[-1],
|
||||||
|
self._config.loop,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _close(self) -> None:
|
||||||
|
"""Best-effort cleanup of PWM, GPIO, and device state."""
|
||||||
|
self._pwm.disable()
|
||||||
|
|
||||||
|
device = self._device
|
||||||
|
if device is not None:
|
||||||
|
try:
|
||||||
|
if device.is_connected:
|
||||||
|
device.generator.set_idle(timeout_s=1.0)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("Failed to set LibreVNA idle mode during shutdown: %s", exc)
|
||||||
|
finally:
|
||||||
|
device.disconnect()
|
||||||
|
self._device = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._markers.reset_to_idle()
|
||||||
|
finally:
|
||||||
|
self._markers.close()
|
||||||
|
self._pwm.close()
|
||||||
|
|
||||||
|
def _run_single_sweep(self) -> None:
|
||||||
|
"""Execute one pass from the first configured frequency to the last."""
|
||||||
|
self._markers.begin_sweep()
|
||||||
|
try:
|
||||||
|
for index, frequency_hz in enumerate(self._config.frequencies_hz):
|
||||||
|
self._pwm.disable()
|
||||||
|
self._set_generator_frequency(frequency_hz)
|
||||||
|
if index > 0:
|
||||||
|
self._markers.toggle_step()
|
||||||
|
self._wait_for_generator_ready()
|
||||||
|
self._emit_pwm_window()
|
||||||
|
finally:
|
||||||
|
self._pwm.disable()
|
||||||
|
self._markers.end_sweep()
|
||||||
|
|
||||||
|
def _set_generator_frequency(self, frequency_hz: int) -> None:
|
||||||
|
"""Transmit next generator frequency to LibreVNA."""
|
||||||
|
device = self._require_device()
|
||||||
|
settings = replace(self._base_generator_settings, frequency_hz=float(frequency_hz))
|
||||||
|
device.generator.configure(settings, timeout_s=max(self._config.settle_timeout_s, 1.0))
|
||||||
|
logger.debug("Generator frequency set to %d Hz", frequency_hz)
|
||||||
|
|
||||||
|
def _wait_for_generator_ready(self) -> None:
|
||||||
|
"""Wait until generator lock telemetry reports ready."""
|
||||||
|
device = self._require_device()
|
||||||
|
device.generator.wait_until_ready(
|
||||||
|
timeout_s=self._config.settle_timeout_s,
|
||||||
|
poll_interval_s=self._config.status_poll_s,
|
||||||
|
)
|
||||||
|
if self._config.post_lock_delay_s > 0.0:
|
||||||
|
time.sleep(self._config.post_lock_delay_s)
|
||||||
|
|
||||||
|
def _emit_pwm_window(self) -> None:
|
||||||
|
"""Enable PWM only during the configured hold window."""
|
||||||
|
if self._config.hold_time_s <= 0.0:
|
||||||
|
return
|
||||||
|
self._pwm.enable()
|
||||||
|
try:
|
||||||
|
time.sleep(self._config.hold_time_s)
|
||||||
|
finally:
|
||||||
|
self._pwm.disable()
|
||||||
|
|
||||||
|
def _require_device(self) -> LibreVNADevice:
|
||||||
|
"""Return connected device or fail fast if runner is not open."""
|
||||||
|
if self._device is None:
|
||||||
|
raise RuntimeError("Generator sweep runner is not open")
|
||||||
|
return self._device
|
||||||
@@ -28,6 +28,7 @@ from .models import (
|
|||||||
DeviceInfo,
|
DeviceInfo,
|
||||||
DeviceLimits,
|
DeviceLimits,
|
||||||
DeviceStatus,
|
DeviceStatus,
|
||||||
|
GeneratorSettings,
|
||||||
Packet,
|
Packet,
|
||||||
StreamHandle,
|
StreamHandle,
|
||||||
SweepResult,
|
SweepResult,
|
||||||
@@ -45,6 +46,7 @@ __all__ = [
|
|||||||
"DeviceInfo",
|
"DeviceInfo",
|
||||||
"DeviceLimits",
|
"DeviceLimits",
|
||||||
"DeviceStatus",
|
"DeviceStatus",
|
||||||
|
"GeneratorSettings",
|
||||||
"HardwareFamily",
|
"HardwareFamily",
|
||||||
"IncompleteSweepError",
|
"IncompleteSweepError",
|
||||||
"LibreVNADevice",
|
"LibreVNADevice",
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""Public controller classes."""
|
"""Public controller classes."""
|
||||||
|
|
||||||
from .config import ConfigController
|
from .config import ConfigController
|
||||||
|
from .generator import GeneratorController
|
||||||
from .vna import VNAController
|
from .vna import VNAController
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ConfigController",
|
"ConfigController",
|
||||||
|
"GeneratorController",
|
||||||
"VNAController",
|
"VNAController",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Signal-generator controller for direct protocol packets."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from ..enums import PacketType
|
||||||
|
from ..exceptions import TimeoutError
|
||||||
|
from ..models import DeviceStatus, GeneratorSettings, Packet
|
||||||
|
from ..session import LibreVNASession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratorController:
|
||||||
|
"""Signal-generator operations built on direct packet protocol."""
|
||||||
|
|
||||||
|
def __init__(self, session: LibreVNASession) -> None:
|
||||||
|
"""Bind generator controller to active session."""
|
||||||
|
self._session = session
|
||||||
|
self._settings: GeneratorSettings | None = None
|
||||||
|
|
||||||
|
def configure(self, settings: GeneratorSettings, *, timeout_s: float = 1.0) -> None:
|
||||||
|
"""Configure generator mode and active CW output."""
|
||||||
|
self._settings = settings
|
||||||
|
self._session.send(Packet(PacketType.GENERATOR, settings), require_ack=True, timeout_s=timeout_s)
|
||||||
|
logger.info(
|
||||||
|
"Generator configured: freq=%.3fHz power=%.2fdBm port=%d correction=%s",
|
||||||
|
settings.frequency_hz,
|
||||||
|
settings.power_dbm,
|
||||||
|
settings.active_port,
|
||||||
|
settings.apply_amplitude_correction,
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_idle(self, *, timeout_s: float = 1.0) -> None:
|
||||||
|
"""Return device to idle mode and stop generator output."""
|
||||||
|
logger.info("Setting LibreVNA idle mode")
|
||||||
|
self._session.send(Packet(PacketType.SET_IDLE), require_ack=True, timeout_s=timeout_s)
|
||||||
|
|
||||||
|
def wait_until_ready(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
timeout_s: float,
|
||||||
|
poll_interval_s: float,
|
||||||
|
) -> DeviceStatus:
|
||||||
|
"""Wait until available lock flags report the generator is ready."""
|
||||||
|
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
|
||||||
|
while True:
|
||||||
|
status = self._session.get_device_status(timeout_s=min(timeout_s, 1.0))
|
||||||
|
lock_values = [value for value in (status.source_locked, status.lo_locked) if value is not None]
|
||||||
|
if lock_values:
|
||||||
|
if all(lock_values):
|
||||||
|
return status
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Generator lock telemetry is unavailable for hardware family {status.family.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise TimeoutError("Timed out waiting for LibreVNA generator lock")
|
||||||
|
time.sleep(min(poll_interval_s, remaining))
|
||||||
@@ -6,6 +6,7 @@ import logging
|
|||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
|
|
||||||
from .api.config import ConfigController
|
from .api.config import ConfigController
|
||||||
|
from .api.generator import GeneratorController
|
||||||
from .api.vna import VNAController
|
from .api.vna import VNAController
|
||||||
from .enums import PacketType
|
from .enums import PacketType
|
||||||
from .models import DeviceInfo, DeviceStatus, Packet, USBDeviceDescriptor
|
from .models import DeviceInfo, DeviceStatus, Packet, USBDeviceDescriptor
|
||||||
@@ -22,6 +23,7 @@ class LibreVNADevice:
|
|||||||
self._session = LibreVNASession()
|
self._session = LibreVNASession()
|
||||||
|
|
||||||
self.vna = VNAController(self._session)
|
self.vna = VNAController(self._session)
|
||||||
|
self.generator = GeneratorController(self._session)
|
||||||
self.config = ConfigController(self._session)
|
self.config = ConfigController(self._session)
|
||||||
|
|
||||||
def __enter__(self) -> LibreVNADevice:
|
def __enter__(self) -> LibreVNADevice:
|
||||||
|
|||||||
@@ -120,6 +120,23 @@ class VNASweepSettings:
|
|||||||
raise ValueError("power sweep requires f_start_hz == f_stop_hz")
|
raise ValueError("power sweep requires f_start_hz == f_stop_hz")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class GeneratorSettings:
|
||||||
|
"""Configuration for LibreVNA signal-generator mode."""
|
||||||
|
|
||||||
|
frequency_hz: float = 1_000_000.0
|
||||||
|
power_dbm: float = -10.0
|
||||||
|
active_port: int = 1
|
||||||
|
apply_amplitude_correction: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate generator settings before transmission."""
|
||||||
|
if self.frequency_hz <= 0:
|
||||||
|
raise ValueError("frequency_hz must be > 0")
|
||||||
|
if self.active_port not in {1, 2}:
|
||||||
|
raise ValueError("active_port must be 1 or 2")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class DeviceConfigVariant:
|
class DeviceConfigVariant:
|
||||||
"""Family-specific device configuration fields."""
|
"""Family-specific device configuration fields."""
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from .codec import (
|
|||||||
decode_packet_payload,
|
decode_packet_payload,
|
||||||
decode_vna_datapoint_payload,
|
decode_vna_datapoint_payload,
|
||||||
encode_device_config_payload,
|
encode_device_config_payload,
|
||||||
|
encode_generator_settings_payload,
|
||||||
encode_packet_payload,
|
encode_packet_payload,
|
||||||
encode_sweep_settings_payload,
|
encode_sweep_settings_payload,
|
||||||
ensure_no_payload_types,
|
ensure_no_payload_types,
|
||||||
@@ -22,6 +23,7 @@ __all__ = [
|
|||||||
"decode_vna_datapoint_payload",
|
"decode_vna_datapoint_payload",
|
||||||
"encode_device_config_payload",
|
"encode_device_config_payload",
|
||||||
"encode_frame",
|
"encode_frame",
|
||||||
|
"encode_generator_settings_payload",
|
||||||
"encode_packet_payload",
|
"encode_packet_payload",
|
||||||
"encode_sweep_settings_payload",
|
"encode_sweep_settings_payload",
|
||||||
"ensure_no_payload_types",
|
"ensure_no_payload_types",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from ..models import (
|
|||||||
DeviceInfo,
|
DeviceInfo,
|
||||||
DeviceLimits,
|
DeviceLimits,
|
||||||
DeviceStatus,
|
DeviceStatus,
|
||||||
|
GeneratorSettings,
|
||||||
Packet,
|
Packet,
|
||||||
VNADatapointPacket,
|
VNADatapointPacket,
|
||||||
VNASweepSettings,
|
VNASweepSettings,
|
||||||
@@ -26,6 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_DEVICE_INFO_STRUCT = struct.Struct("<HBBBBcQQIIHhhIIBQBH")
|
_DEVICE_INFO_STRUCT = struct.Struct("<HBBBBcQQIIHhhIIBQBH")
|
||||||
_SWEEP_SETTINGS_STRUCT = struct.Struct("<QQHIhBHhH")
|
_SWEEP_SETTINGS_STRUCT = struct.Struct("<QQHIhBHhH")
|
||||||
|
_GENERATOR_SETTINGS_STRUCT = struct.Struct("<QhBB")
|
||||||
_DEVICE_CONFIG_V1_STRUCT = struct.Struct("<IBHB")
|
_DEVICE_CONFIG_V1_STRUCT = struct.Struct("<IBHB")
|
||||||
_DEVICE_CONFIG_VFF_STRUCT = struct.Struct("<IIIBH")
|
_DEVICE_CONFIG_VFF_STRUCT = struct.Struct("<IIIBH")
|
||||||
_DEVICE_CONFIG_VFE_STRUCT = struct.Struct("<H")
|
_DEVICE_CONFIG_VFE_STRUCT = struct.Struct("<H")
|
||||||
@@ -275,6 +277,16 @@ def encode_sweep_settings_payload(settings: VNASweepSettings) -> bytes:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_generator_settings_payload(settings: GeneratorSettings) -> bytes:
|
||||||
|
"""Encode ``GeneratorSettings`` payload."""
|
||||||
|
return _GENERATOR_SETTINGS_STRUCT.pack(
|
||||||
|
int(round(settings.frequency_hz)),
|
||||||
|
int(round(settings.power_dbm * 100.0)),
|
||||||
|
int(settings.active_port),
|
||||||
|
int(bool(settings.apply_amplitude_correction)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_device_config(payload: bytes, family: HardwareFamily) -> DeviceConfigVariant:
|
def parse_device_config(payload: bytes, family: HardwareFamily) -> DeviceConfigVariant:
|
||||||
"""Decode family-specific `DeviceConfiguration` payload."""
|
"""Decode family-specific `DeviceConfiguration` payload."""
|
||||||
if len(payload) != 15:
|
if len(payload) != 15:
|
||||||
@@ -378,6 +390,8 @@ def encode_packet_payload(packet_type: PacketType, payload: object) -> bytes:
|
|||||||
|
|
||||||
if packet_type == PacketType.SWEEP_SETTINGS and isinstance(payload, VNASweepSettings):
|
if packet_type == PacketType.SWEEP_SETTINGS and isinstance(payload, VNASweepSettings):
|
||||||
return encode_sweep_settings_payload(payload)
|
return encode_sweep_settings_payload(payload)
|
||||||
|
if packet_type == PacketType.GENERATOR and isinstance(payload, GeneratorSettings):
|
||||||
|
return encode_generator_settings_payload(payload)
|
||||||
if packet_type == PacketType.DEVICE_CONFIGURATION and isinstance(payload, DeviceConfigVariant):
|
if packet_type == PacketType.DEVICE_CONFIGURATION and isinstance(payload, DeviceConfigVariant):
|
||||||
return encode_device_config_payload(payload)
|
return encode_device_config_payload(payload)
|
||||||
|
|
||||||
@@ -396,6 +410,7 @@ NO_PAYLOAD_PACKET_TYPES = {
|
|||||||
PacketType.REQUEST_DEVICE_CONFIGURATION,
|
PacketType.REQUEST_DEVICE_CONFIGURATION,
|
||||||
PacketType.REQUEST_DEVICE_STATUS,
|
PacketType.REQUEST_DEVICE_STATUS,
|
||||||
PacketType.INITIATE_SWEEP,
|
PacketType.INITIATE_SWEEP,
|
||||||
|
PacketType.SET_IDLE,
|
||||||
PacketType.RESET_DEVICE_CONFIGURATION,
|
PacketType.RESET_DEVICE_CONFIGURATION,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Convert legacy preprocess set storage into the current two-channel format."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||||
|
from python_app.storage.npz_store import NpzStore
|
||||||
|
|
||||||
|
|
||||||
|
LEGACY_KIND_MAP: dict[str, str] = {
|
||||||
|
"calibration": "s21_calibration",
|
||||||
|
"reference": "s21_reference",
|
||||||
|
"s21_calibration": "s21_calibration",
|
||||||
|
"s21_reference": "s21_reference",
|
||||||
|
"s11_open": "s11_open",
|
||||||
|
"s11_short": "s11_short",
|
||||||
|
"s11_load": "s11_load",
|
||||||
|
"s11_reference": "s11_reference",
|
||||||
|
}
|
||||||
|
|
||||||
|
S21_ONLY_TARGET_KINDS = {"s21_calibration", "s21_reference"}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Convert old preprocess-set storage from a legacy python_app/data tree into the "
|
||||||
|
"current format required by the new GUI/runtime."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"source_data_dir",
|
||||||
|
type=Path,
|
||||||
|
help="Path to legacy python_app/data directory from the old project copy.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"output_data_dir",
|
||||||
|
type=Path,
|
||||||
|
help="Destination directory where converted sets will be written in the new format.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--overwrite",
|
||||||
|
action="store_true",
|
||||||
|
help="Allow overwriting already converted destination sets.",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path) -> dict[str, Any]:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError(f"JSON root must be object: {path}")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _read_combo_position(combo_payload: dict[str, Any], *, primary_key: str, alias_key: str) -> int:
|
||||||
|
if primary_key in combo_payload:
|
||||||
|
return int(combo_payload[primary_key])
|
||||||
|
if alias_key in combo_payload:
|
||||||
|
return int(combo_payload[alias_key])
|
||||||
|
raise KeyError(f"Missing combo position field: {primary_key}/{alias_key}")
|
||||||
|
|
||||||
|
|
||||||
|
def _combo_suffix(input_pos: int, output_pos: int) -> str:
|
||||||
|
return f"i{input_pos}_o{output_pos}"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_array(arrays: Any, key: str, *, dtype: np.dtype[Any], label: str) -> np.ndarray:
|
||||||
|
if key not in arrays:
|
||||||
|
raise KeyError(f"Missing {label} array '{key}' in NPZ archive")
|
||||||
|
return np.asarray(arrays[key], dtype=dtype).reshape(-1)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_legacy_collection(meta_path: Path, npz_path: Path, *, target_kind: str) -> SweepCollection:
|
||||||
|
meta = _load_json(meta_path)
|
||||||
|
combos_payload = meta.get("combos")
|
||||||
|
if not isinstance(combos_payload, list):
|
||||||
|
raise ValueError(f"Expected 'combos' list in {meta_path}")
|
||||||
|
|
||||||
|
with np.load(npz_path) as arrays:
|
||||||
|
traces: list[TraceData] = []
|
||||||
|
for combo_payload in combos_payload:
|
||||||
|
if not isinstance(combo_payload, dict):
|
||||||
|
raise ValueError(f"Expected combo object in {meta_path}")
|
||||||
|
|
||||||
|
input_pos = _read_combo_position(combo_payload, primary_key="input", alias_key="input_pos")
|
||||||
|
output_pos = _read_combo_position(combo_payload, primary_key="output", alias_key="output_pos")
|
||||||
|
suffix = _combo_suffix(input_pos, output_pos)
|
||||||
|
|
||||||
|
freq_key = str(combo_payload.get("freq_key") or f"freq_{suffix}")
|
||||||
|
s21_key = str(combo_payload.get("s21_key") or f"s21_{suffix}")
|
||||||
|
s11_key = str(combo_payload.get("s11_key") or f"s11_{suffix}")
|
||||||
|
|
||||||
|
frequency_hz = _load_array(arrays, freq_key, dtype=np.float32, label="frequency")
|
||||||
|
s21 = _load_array(arrays, s21_key, dtype=np.complex64, label="S21")
|
||||||
|
|
||||||
|
if frequency_hz.shape != s21.shape:
|
||||||
|
raise ValueError(f"Frequency/S21 shape mismatch in {npz_path}: {frequency_hz.shape} vs {s21.shape}")
|
||||||
|
|
||||||
|
if s11_key in arrays:
|
||||||
|
s11 = _load_array(arrays, s11_key, dtype=np.complex64, label="S11")
|
||||||
|
elif target_kind in S21_ONLY_TARGET_KINDS:
|
||||||
|
s11 = np.zeros_like(s21, dtype=np.complex64)
|
||||||
|
else:
|
||||||
|
raise KeyError(
|
||||||
|
f"Missing S11 array '{s11_key}' in {npz_path}; "
|
||||||
|
f"cannot convert target kind '{target_kind}' without real S11 data"
|
||||||
|
)
|
||||||
|
|
||||||
|
if frequency_hz.shape != s11.shape:
|
||||||
|
raise ValueError(f"Frequency/S11 shape mismatch in {npz_path}: {frequency_hz.shape} vs {s11.shape}")
|
||||||
|
|
||||||
|
traces.append(
|
||||||
|
TraceData(
|
||||||
|
combo=ComboKey(input_pos=input_pos, output_pos=output_pos),
|
||||||
|
frequency_hz=frequency_hz,
|
||||||
|
s11=s11,
|
||||||
|
s21=s21,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SweepCollection(
|
||||||
|
collection_id=int(meta.get("collection_id", 0)),
|
||||||
|
monotonic_ns=int(meta.get("monotonic_ns", 0)),
|
||||||
|
traces=traces,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_one_set(
|
||||||
|
store: NpzStore,
|
||||||
|
*,
|
||||||
|
output_root: Path,
|
||||||
|
source_kind: str,
|
||||||
|
target_kind: str,
|
||||||
|
radar_key: str,
|
||||||
|
meta_path: Path,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> None:
|
||||||
|
set_name = meta_path.stem
|
||||||
|
npz_path = meta_path.with_suffix(".npz")
|
||||||
|
if not npz_path.exists():
|
||||||
|
raise FileNotFoundError(f"Missing NPZ archive for set '{set_name}': {npz_path}")
|
||||||
|
|
||||||
|
target_dir = output_root / target_kind / radar_key
|
||||||
|
target_json = target_dir / f"{set_name}.json"
|
||||||
|
target_npz = target_dir / f"{set_name}.npz"
|
||||||
|
if not overwrite and (target_json.exists() or target_npz.exists()):
|
||||||
|
raise FileExistsError(f"Destination set already exists: {target_dir / set_name}")
|
||||||
|
|
||||||
|
collection = _load_legacy_collection(meta_path, npz_path, target_kind=target_kind)
|
||||||
|
store.save_set(target_kind, radar_key, set_name, collection)
|
||||||
|
print(
|
||||||
|
f"[converted] {source_kind}/{radar_key}/{set_name} -> "
|
||||||
|
f"{target_kind}/{radar_key}/{set_name} (traces={len(collection.traces)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = _build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
source_root = args.source_data_dir.expanduser().resolve()
|
||||||
|
output_root = args.output_data_dir.expanduser().resolve()
|
||||||
|
|
||||||
|
if not source_root.exists():
|
||||||
|
raise FileNotFoundError(f"Source data directory does not exist: {source_root}")
|
||||||
|
if source_root == output_root:
|
||||||
|
raise ValueError("Source and output directories must be different")
|
||||||
|
|
||||||
|
store = NpzStore(output_root)
|
||||||
|
converted_count = 0
|
||||||
|
skipped_kind_count = 0
|
||||||
|
error_messages: list[str] = []
|
||||||
|
|
||||||
|
for source_kind_dir in sorted(path for path in source_root.iterdir() if path.is_dir()):
|
||||||
|
source_kind = source_kind_dir.name
|
||||||
|
target_kind = LEGACY_KIND_MAP.get(source_kind)
|
||||||
|
if target_kind is None:
|
||||||
|
skipped_kind_count += 1
|
||||||
|
print(f"[skip-kind] {source_kind_dir}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for radar_key_dir in sorted(path for path in source_kind_dir.iterdir() if path.is_dir()):
|
||||||
|
radar_key = radar_key_dir.name
|
||||||
|
for meta_path in sorted(radar_key_dir.glob("*.json")):
|
||||||
|
try:
|
||||||
|
_convert_one_set(
|
||||||
|
store,
|
||||||
|
output_root=output_root,
|
||||||
|
source_kind=source_kind,
|
||||||
|
target_kind=target_kind,
|
||||||
|
radar_key=radar_key,
|
||||||
|
meta_path=meta_path,
|
||||||
|
overwrite=bool(args.overwrite),
|
||||||
|
)
|
||||||
|
converted_count += 1
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
error_messages.append(f"{meta_path}: {exc}")
|
||||||
|
print(f"[error] {meta_path}: {exc}")
|
||||||
|
|
||||||
|
print(
|
||||||
|
"\nConversion summary:\n"
|
||||||
|
f" source root: {source_root}\n"
|
||||||
|
f" output root: {output_root}\n"
|
||||||
|
f" converted sets: {converted_count}\n"
|
||||||
|
f" skipped kinds: {skipped_kind_count}\n"
|
||||||
|
f" errors: {len(error_messages)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if error_messages:
|
||||||
|
return 1
|
||||||
|
if converted_count == 0:
|
||||||
|
print("No convertible preprocess sets were found.")
|
||||||
|
return 2
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Standalone LibreVNA generator sweep driven by a local Python config."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from python_app.generator_sweep.config import resolve_generator_sweep_config
|
||||||
|
from python_app.scripts.librevna_generator_sweep_config import CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Load local generator config and execute the sweep."""
|
||||||
|
resolved = resolve_generator_sweep_config(CONFIG)
|
||||||
|
logging.basicConfig(
|
||||||
|
level=resolved.log_level,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
from python_app.generator_sweep.runner import GeneratorSweepRunner
|
||||||
|
|
||||||
|
runner = GeneratorSweepRunner(CONFIG)
|
||||||
|
runner.run()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Local editable config for ``librevna_generator_sweep.py``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from python_app.generator_sweep.config import GeneratorSweepConfig
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG = GeneratorSweepConfig(
|
||||||
|
log_level="INFO", # DEBUG, INFO, WARNING, ERROR, or CRITICAL.
|
||||||
|
serial=None, # None or exact serial string, for example "206930A15532".
|
||||||
|
strict_protocol_version=14, # Exact value for the connected device: 14.
|
||||||
|
start_hz=100_000_000.0, # Script constraint: > 0. Device reports frequency range 0.0 .. 6_000_000_000.0 Hz.
|
||||||
|
stop_hz=6_000_000_000.0, # Script constraint: >= start_hz. Device reports frequency range 0.0 .. 6_000_000_000.0 Hz.
|
||||||
|
step_hz=0, # > 0 to use step mode, or 0 to disable and use points mode.
|
||||||
|
points=501, # 0 to disable, or >= 2 to use points mode; if start_hz == stop_hz then only 1 is allowed.
|
||||||
|
hold_time_ms=50.0, # >= 0 ms. Float values like 0.5 are allowed.
|
||||||
|
loop=True, # True or False.
|
||||||
|
port=1, # Only 1 or 2.
|
||||||
|
power_dbm=-10.0, # Device reports source power range -40.0 .. 0.0 dBm.
|
||||||
|
amplitude_correction=False, # True = apply source amplitude calibration, False = use raw generator level.
|
||||||
|
settle_timeout_ms=1_000, # > 0 ms.
|
||||||
|
status_poll_ms=10.0, # > 0 ms. Float values like 0.2 are allowed.
|
||||||
|
post_lock_delay_us=0, # >= 0 us.
|
||||||
|
gpio_chip="/dev/gpiochip0", # Non-empty Linux GPIO chip path.
|
||||||
|
sweep_start_pin=5, # BCM GPIO, >= 0, must differ from curr_step_pin and pwm_pin.
|
||||||
|
curr_step_pin=6, # BCM GPIO, >= 0, must differ from sweep_start_pin and pwm_pin.
|
||||||
|
curr_step_initial_level=0, # Only 0 or 1.
|
||||||
|
pwm_pin=12, # Only 12, 13, 18, or 19 on Raspberry Pi 5.
|
||||||
|
pwm_frequency_hz=2_000_000, # > 0 Hz.
|
||||||
|
pwm_duty_cycle=0.5, # Range: 0.0 < value <= 1.0.
|
||||||
|
)
|
||||||
@@ -2,3 +2,4 @@ numpy>=1.26,<3
|
|||||||
libusb1>=3.1
|
libusb1>=3.1
|
||||||
PyQt6>=6.6
|
PyQt6>=6.6
|
||||||
pyqtgraph>=0.13.7
|
pyqtgraph>=0.13.7
|
||||||
|
rpi-hardware-pwm>=0.2.2,<1
|
||||||
|
|||||||
Reference in New Issue
Block a user