added generator mode

This commit is contained in:
Ayzen
2026-04-01 13:00:01 +03:00
parent 43d1c226a1
commit 4abc95c372
16 changed files with 936 additions and 0 deletions
View File
+31
View File
@@ -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}")
+202
View File
@@ -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),
)
+92
View File
@@ -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"
+208
View File
@@ -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.
)
+1
View File
@@ -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