some features added
This commit is contained in:
4007
vna_system/binary_input/sweep_example/example.json
Normal file
4007
vna_system/binary_input/sweep_example/example.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -12,8 +12,15 @@ import serial
|
|||||||
from vna_system.core import config as cfg
|
from vna_system.core import config as cfg
|
||||||
from vna_system.core.acquisition.port_manager import VNAPortLocator
|
from vna_system.core.acquisition.port_manager import VNAPortLocator
|
||||||
from vna_system.core.acquisition.sweep_buffer import SweepBuffer
|
from vna_system.core.acquisition.sweep_buffer import SweepBuffer
|
||||||
|
from vna_system.core.acquisition.sound_player import SoundPlayer
|
||||||
from vna_system.core.logging.logger import get_component_logger
|
from vna_system.core.logging.logger import get_component_logger
|
||||||
|
|
||||||
|
# Import simulator if enabled
|
||||||
|
if cfg.USE_SIMULATOR:
|
||||||
|
from vna_system.core.acquisition.simulator import VNASimulator
|
||||||
|
else:
|
||||||
|
VNASimulator = None # type: ignore
|
||||||
|
|
||||||
logger = get_component_logger(__file__)
|
logger = get_component_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
@ -30,6 +37,16 @@ class VNADataAcquisition:
|
|||||||
self.vna_port_locator = VNAPortLocator()
|
self.vna_port_locator = VNAPortLocator()
|
||||||
self._sweep_buffer = SweepBuffer()
|
self._sweep_buffer = SweepBuffer()
|
||||||
|
|
||||||
|
# Sound player for sweep notifications
|
||||||
|
sound_file = Path(cfg.BASE_DIR) / "core" / "sound_sample.mp3"
|
||||||
|
self._sound_player = SoundPlayer(sound_file)
|
||||||
|
|
||||||
|
# Simulator mode
|
||||||
|
self._simulator = None
|
||||||
|
if cfg.USE_SIMULATOR:
|
||||||
|
self._simulator = VNASimulator()
|
||||||
|
logger.info("VNA Simulator mode enabled")
|
||||||
|
|
||||||
# Control flags
|
# Control flags
|
||||||
self._running: bool = False
|
self._running: bool = False
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
@ -45,7 +62,7 @@ class VNADataAcquisition:
|
|||||||
self._collected_rx_payloads: list[bytes] = []
|
self._collected_rx_payloads: list[bytes] = []
|
||||||
self._meas_cmds_in_sweep: int = 0
|
self._meas_cmds_in_sweep: int = 0
|
||||||
|
|
||||||
logger.debug("VNADataAcquisition initialized", baud=self.baud)
|
logger.debug("VNADataAcquisition initialized", baud=self.baud, simulator_mode=cfg.USE_SIMULATOR)
|
||||||
|
|
||||||
def _get_current_bin_path(self) -> Path | None:
|
def _get_current_bin_path(self) -> Path | None:
|
||||||
"""Get the path to the current binary input file from JSON config."""
|
"""Get the path to the current binary input file from JSON config."""
|
||||||
@ -175,8 +192,55 @@ class VNADataAcquisition:
|
|||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
# Acquisition loop
|
# Acquisition loop
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
|
def _simulator_acquisition_loop(self) -> None:
|
||||||
|
"""Simplified acquisition loop for simulator mode."""
|
||||||
|
logger.info("Starting simulator acquisition loop")
|
||||||
|
|
||||||
|
while self._running and not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
# Honor pause
|
||||||
|
if self._paused:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get simulated sweep with noise
|
||||||
|
points, timestamp = self._simulator.get_sweep()
|
||||||
|
|
||||||
|
# Add sweep to buffer
|
||||||
|
sweep_number = self._sweep_buffer.add_sweep(points, timestamp=timestamp)
|
||||||
|
logger.info(
|
||||||
|
"Simulated sweep collected",
|
||||||
|
sweep_number=sweep_number,
|
||||||
|
points=len(points),
|
||||||
|
timestamp=timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
# Play sweep notification sound
|
||||||
|
self._sound_player.play()
|
||||||
|
|
||||||
|
# Handle single-sweep mode transitions
|
||||||
|
if not self._continuous_mode:
|
||||||
|
if self._single_sweep_requested:
|
||||||
|
self._single_sweep_requested = False
|
||||||
|
logger.info("Single simulated sweep completed; pausing acquisition")
|
||||||
|
self.pause()
|
||||||
|
else:
|
||||||
|
self.pause()
|
||||||
|
else:
|
||||||
|
# In continuous mode, add a small delay between sweeps
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error("Simulator acquisition loop error", error=repr(exc))
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
def _acquisition_loop(self) -> None:
|
def _acquisition_loop(self) -> None:
|
||||||
"""Main acquisition loop executed by the background thread."""
|
"""Main acquisition loop executed by the background thread."""
|
||||||
|
# Use simulator loop if simulator is enabled
|
||||||
|
if self._simulator is not None:
|
||||||
|
self._simulator_acquisition_loop()
|
||||||
|
return
|
||||||
|
|
||||||
while self._running and not self._stop_event.is_set():
|
while self._running and not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
# Honor pause
|
# Honor pause
|
||||||
@ -305,6 +369,9 @@ class VNADataAcquisition:
|
|||||||
expected=cfg.EXPECTED_POINTS_PER_SWEEP,
|
expected=cfg.EXPECTED_POINTS_PER_SWEEP,
|
||||||
actual=len(all_points),
|
actual=len(all_points),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Play sweep notification sound
|
||||||
|
self._sound_player.play()
|
||||||
else:
|
else:
|
||||||
logger.warning("No points parsed for sweep")
|
logger.warning("No points parsed for sweep")
|
||||||
|
|
||||||
|
|||||||
150
vna_system/core/acquisition/simulator.py
Normal file
150
vna_system/core/acquisition/simulator.py
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
VNA Simulator Module
|
||||||
|
|
||||||
|
This module provides a simulator mode that loads sweep data from a JSON file
|
||||||
|
and adds configurable Gaussian noise to simulate real device behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple, Optional
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from vna_system.core import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class VNASimulator:
|
||||||
|
"""
|
||||||
|
Simulator for VNA device that loads sweep data from file and adds noise.
|
||||||
|
|
||||||
|
This simulator reads a reference sweep from a JSON file and adds Gaussian
|
||||||
|
noise to the real and imaginary components to simulate measurement variations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
sweep_file: Optional[Path] = None,
|
||||||
|
noise_level: Optional[float] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the VNA simulator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sweep_file: Path to JSON file containing sweep data.
|
||||||
|
If None, uses config.SIMULATOR_SWEEP_FILE
|
||||||
|
noise_level: Standard deviation of Gaussian noise to add.
|
||||||
|
If None, uses config.SIMULATOR_NOISE_LEVEL
|
||||||
|
"""
|
||||||
|
self.sweep_file = sweep_file or config.SIMULATOR_SWEEP_FILE
|
||||||
|
self.noise_level = noise_level if noise_level is not None else config.SIMULATOR_NOISE_LEVEL
|
||||||
|
|
||||||
|
self._reference_points: List[Tuple[float, float]] = []
|
||||||
|
self._sweep_counter = 0
|
||||||
|
self._load_reference_sweep()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"VNA Simulator initialized with file: {self.sweep_file}, "
|
||||||
|
f"noise level: {self.noise_level}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_reference_sweep(self) -> None:
|
||||||
|
"""Load reference sweep data from JSON file."""
|
||||||
|
try:
|
||||||
|
with open(self.sweep_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Extract points from JSON structure
|
||||||
|
# Expected format: {"sweep_number": N, "timestamp": T, "points": [[real, imag], ...]}
|
||||||
|
if 'points' not in data:
|
||||||
|
raise ValueError(f"JSON file missing 'points' field: {self.sweep_file}")
|
||||||
|
|
||||||
|
self._reference_points = [
|
||||||
|
(float(point[0]), float(point[1]))
|
||||||
|
for point in data['points']
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Loaded {len(self._reference_points)} points from reference sweep "
|
||||||
|
f"(sweep_number: {data.get('sweep_number', 'N/A')})"
|
||||||
|
)
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error(f"Simulator sweep file not found: {self.sweep_file}")
|
||||||
|
raise
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Failed to parse JSON file {self.sweep_file}: {e}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load reference sweep: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_sweep(self) -> Tuple[List[Tuple[float, float]], float]:
|
||||||
|
"""
|
||||||
|
Generate a simulated sweep with added noise.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (points, timestamp) where:
|
||||||
|
- points: List of (real, imag) tuples with added noise
|
||||||
|
- timestamp: Current Unix timestamp
|
||||||
|
"""
|
||||||
|
time.sleep(1)
|
||||||
|
timestamp = time.time()
|
||||||
|
self._sweep_counter += 1
|
||||||
|
|
||||||
|
# Add Gaussian noise to each point
|
||||||
|
noisy_points = []
|
||||||
|
for real, imag in self._reference_points:
|
||||||
|
# Add independent noise to real and imaginary parts
|
||||||
|
noise_real = np.random.normal(0, self.noise_level)
|
||||||
|
noise_imag = np.random.normal(0, self.noise_level)
|
||||||
|
noisy_real = real + noise_real
|
||||||
|
noisy_imag = imag + noise_imag
|
||||||
|
noisy_points.append((noisy_real, noisy_imag))
|
||||||
|
|
||||||
|
# Log statistics about the noise added
|
||||||
|
if len(noisy_points) > 0:
|
||||||
|
first_orig = self._reference_points[0]
|
||||||
|
first_noisy = noisy_points[0]
|
||||||
|
logger.debug(
|
||||||
|
f"Simulated sweep #{self._sweep_counter}: "
|
||||||
|
f"noise_level={self.noise_level:.2e}, "
|
||||||
|
f"first_point: orig=({first_orig[0]:.6e}, {first_orig[1]:.6e}), "
|
||||||
|
f"noisy=({first_noisy[0]:.6e}, {first_noisy[1]:.6e})"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Generated simulated sweep #{self._sweep_counter} with "
|
||||||
|
f"{len(noisy_points)} points at timestamp {timestamp:.6f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return noisy_points, timestamp
|
||||||
|
|
||||||
|
def get_point_count(self) -> int:
|
||||||
|
"""
|
||||||
|
Get the number of points in the reference sweep.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of points in each simulated sweep
|
||||||
|
"""
|
||||||
|
return len(self._reference_points)
|
||||||
|
|
||||||
|
def reset_counter(self) -> None:
|
||||||
|
"""Reset the internal sweep counter."""
|
||||||
|
self._sweep_counter = 0
|
||||||
|
logger.debug("Simulator sweep counter reset")
|
||||||
|
|
||||||
|
|
||||||
|
def create_simulator() -> VNASimulator:
|
||||||
|
"""
|
||||||
|
Factory function to create a VNA simulator instance.
|
||||||
|
|
||||||
|
Uses configuration from config module.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured VNASimulator instance
|
||||||
|
"""
|
||||||
|
return VNASimulator()
|
||||||
101
vna_system/core/acquisition/sound_player.py
Normal file
101
vna_system/core/acquisition/sound_player.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"""
|
||||||
|
Cross-platform sound player for sweep notifications.
|
||||||
|
|
||||||
|
Supports Windows and Linux platforms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import platform
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from vna_system.core.logging.logger import get_component_logger
|
||||||
|
|
||||||
|
logger = get_component_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
class SoundPlayer:
|
||||||
|
"""Cross-platform sound player using system-specific APIs."""
|
||||||
|
|
||||||
|
def __init__(self, sound_file: Path):
|
||||||
|
"""
|
||||||
|
Initialize sound player with a sound file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sound_file: Path to the sound file (mp3, wav, etc.)
|
||||||
|
"""
|
||||||
|
self.sound_file = sound_file
|
||||||
|
self.system = platform.system()
|
||||||
|
self._player = None
|
||||||
|
|
||||||
|
# Validate sound file exists
|
||||||
|
if not sound_file.exists():
|
||||||
|
logger.warning("Sound file not found", path=str(sound_file))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Initialize platform-specific player
|
||||||
|
if self.system == "Windows":
|
||||||
|
self._init_windows_player()
|
||||||
|
elif self.system == "Linux":
|
||||||
|
self._init_linux_player()
|
||||||
|
else:
|
||||||
|
logger.warning("Unsupported platform for sound playback", platform=self.system)
|
||||||
|
|
||||||
|
def _init_windows_player(self):
|
||||||
|
"""Initialize Windows-specific sound player using winsound."""
|
||||||
|
try:
|
||||||
|
import winsound
|
||||||
|
self._player = lambda: winsound.PlaySound(
|
||||||
|
str(self.sound_file),
|
||||||
|
winsound.SND_FILENAME | winsound.SND_ASYNC | winsound.SND_NODEFAULT
|
||||||
|
)
|
||||||
|
logger.debug("Windows sound player initialized")
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("winsound not available on Windows")
|
||||||
|
self._player = None
|
||||||
|
|
||||||
|
def _init_linux_player(self):
|
||||||
|
"""Initialize Linux-specific sound player using subprocess."""
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
# Try to find available audio players
|
||||||
|
players = ["paplay", "aplay", "mpg123", "ffplay"]
|
||||||
|
available_player = None
|
||||||
|
|
||||||
|
for player in players:
|
||||||
|
if shutil.which(player):
|
||||||
|
available_player = player
|
||||||
|
break
|
||||||
|
|
||||||
|
if available_player:
|
||||||
|
def linux_play():
|
||||||
|
try:
|
||||||
|
# Run player silently in background
|
||||||
|
subprocess.Popen(
|
||||||
|
[available_player, str(self.sound_file)],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to play sound on Linux", error=repr(exc))
|
||||||
|
|
||||||
|
self._player = linux_play
|
||||||
|
logger.debug("Linux sound player initialized", player=available_player)
|
||||||
|
else:
|
||||||
|
logger.warning("No audio player found on Linux", tried=players)
|
||||||
|
self._player = None
|
||||||
|
|
||||||
|
def play(self):
|
||||||
|
"""Play the sound asynchronously (non-blocking)."""
|
||||||
|
if self._player is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Play in a background thread to avoid blocking
|
||||||
|
thread = threading.Thread(target=self._player, daemon=True, name="SoundPlayer")
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def play_sync(self):
|
||||||
|
"""Play the sound synchronously (blocking)."""
|
||||||
|
if self._player is None:
|
||||||
|
return
|
||||||
|
self._player()
|
||||||
@ -65,7 +65,7 @@ class SweepBuffer:
|
|||||||
# ------------------------------
|
# ------------------------------
|
||||||
# Core API
|
# Core API
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
def add_sweep(self, points: list[Point]) -> int:
|
def add_sweep(self, points: list[Point], timestamp: float | None = None) -> int:
|
||||||
"""
|
"""
|
||||||
Add a new sweep to the buffer.
|
Add a new sweep to the buffer.
|
||||||
|
|
||||||
@ -73,13 +73,16 @@ class SweepBuffer:
|
|||||||
----------
|
----------
|
||||||
points:
|
points:
|
||||||
Sequence of (real, imag) tuples representing a sweep.
|
Sequence of (real, imag) tuples representing a sweep.
|
||||||
|
timestamp:
|
||||||
|
Optional UNIX timestamp. If None, current time is used.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
int
|
int
|
||||||
The assigned sweep number for the newly added sweep.
|
The assigned sweep number for the newly added sweep.
|
||||||
"""
|
"""
|
||||||
timestamp = time.time()
|
if timestamp is None:
|
||||||
|
timestamp = time.time()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._sweep_counter += 1
|
self._sweep_counter += 1
|
||||||
sweep = SweepData(
|
sweep = SweepData(
|
||||||
|
|||||||
@ -32,6 +32,13 @@ TX_CHUNK_SIZE = 64 * 1024
|
|||||||
VNA_VID = 0x0483 # STMicroelectronics
|
VNA_VID = 0x0483 # STMicroelectronics
|
||||||
VNA_PID = 0x5740 # STM32 Virtual ComPort
|
VNA_PID = 0x5740 # STM32 Virtual ComPort
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Simulator mode settings
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
USE_SIMULATOR = True # Set to True to use simulator instead of real device
|
||||||
|
SIMULATOR_SWEEP_FILE = BASE_DIR / "binary_input" / "sweep_example" / "example.json"
|
||||||
|
SIMULATOR_NOISE_LEVEL = 100 # Standard deviation of Gaussian noise to add to real and imaginary parts
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Sweep detection and parsing constants
|
# Sweep detection and parsing constants
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@ -299,7 +299,7 @@ class BaseProcessor:
|
|||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
# Data path: accept new sweep, recompute, produce result
|
# Data path: accept new sweep, recompute, produce result
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
def add_sweep_data(self, sweep_data: Any, calibrated_data: Any, vna_config: ConfigPreset | None, reference_data: Any = None):
|
def add_sweep_data(self, sweep_data: Any, calibrated_data: Any, vna_config: ConfigPreset | None, reference_data: Any = None, reference_info: Any = None):
|
||||||
"""
|
"""
|
||||||
Add the latest sweep to the in-memory history and trigger recalculation.
|
Add the latest sweep to the in-memory history and trigger recalculation.
|
||||||
|
|
||||||
@ -313,6 +313,8 @@ class BaseProcessor:
|
|||||||
Snapshot of VNA settings (dataclass or pydantic model supported).
|
Snapshot of VNA settings (dataclass or pydantic model supported).
|
||||||
reference_data:
|
reference_data:
|
||||||
Open air reference sweep data for background subtraction/normalization.
|
Open air reference sweep data for background subtraction/normalization.
|
||||||
|
reference_info:
|
||||||
|
ReferenceInfo object with metadata (name, description, etc.) about the reference.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
@ -326,6 +328,7 @@ class BaseProcessor:
|
|||||||
"calibrated_data": calibrated_data,
|
"calibrated_data": calibrated_data,
|
||||||
"vna_config": self._snapshot_vna_config(vna_config),
|
"vna_config": self._snapshot_vna_config(vna_config),
|
||||||
"reference_data": reference_data,
|
"reference_data": reference_data,
|
||||||
|
"reference_info": reference_info,
|
||||||
"timestamp": datetime.now().timestamp(),
|
"timestamp": datetime.now().timestamp(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -614,7 +617,6 @@ class BaseProcessor:
|
|||||||
reference_data = entry.get("reference_data")
|
reference_data = entry.get("reference_data")
|
||||||
|
|
||||||
exported.append({
|
exported.append({
|
||||||
"sweep_number": sweep_data.sweep_number if sweep_data else None,
|
|
||||||
"timestamp": float(entry.get("timestamp")) if entry.get("timestamp") is not None else None,
|
"timestamp": float(entry.get("timestamp")) if entry.get("timestamp") is not None else None,
|
||||||
"sweep_points": self._points_to_list(getattr(sweep_data, "points", [])),
|
"sweep_points": self._points_to_list(getattr(sweep_data, "points", [])),
|
||||||
"calibrated_points": self._points_to_list(getattr(calibrated_data, "points", [])),
|
"calibrated_points": self._points_to_list(getattr(calibrated_data, "points", [])),
|
||||||
@ -639,28 +641,29 @@ class BaseProcessor:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._sweep_history.clear()
|
self._sweep_history.clear()
|
||||||
|
|
||||||
for entry in history_data:
|
for idx, entry in enumerate(history_data):
|
||||||
sweep_points = entry.get("sweep_points", [])
|
sweep_points = entry.get("sweep_points", [])
|
||||||
calibrated_points = entry.get("calibrated_points", [])
|
calibrated_points = entry.get("calibrated_points", [])
|
||||||
reference_points = entry.get("reference_points", [])
|
reference_points = entry.get("reference_points", [])
|
||||||
|
|
||||||
# Reconstruct SweepData objects
|
# Reconstruct SweepData objects
|
||||||
|
# Use sequential index as sweep_number since it's not stored
|
||||||
sweep_data = SweepData(
|
sweep_data = SweepData(
|
||||||
sweep_number=entry.get("sweep_number", 0),
|
sweep_number=idx,
|
||||||
timestamp=entry.get("timestamp", 0.0),
|
timestamp=entry.get("timestamp", 0.0),
|
||||||
points=sweep_points,
|
points=sweep_points,
|
||||||
total_points=len(sweep_points)
|
total_points=len(sweep_points)
|
||||||
) if sweep_points else None
|
) if sweep_points else None
|
||||||
|
|
||||||
calibrated_data = SweepData(
|
calibrated_data = SweepData(
|
||||||
sweep_number=entry.get("sweep_number", 0),
|
sweep_number=idx,
|
||||||
timestamp=entry.get("timestamp", 0.0),
|
timestamp=entry.get("timestamp", 0.0),
|
||||||
points=calibrated_points,
|
points=calibrated_points,
|
||||||
total_points=len(calibrated_points)
|
total_points=len(calibrated_points)
|
||||||
) if calibrated_points else None
|
) if calibrated_points else None
|
||||||
|
|
||||||
reference_data = SweepData(
|
reference_data = SweepData(
|
||||||
sweep_number=entry.get("sweep_number", 0),
|
sweep_number=idx,
|
||||||
timestamp=entry.get("timestamp", 0.0),
|
timestamp=entry.get("timestamp", 0.0),
|
||||||
points=reference_points,
|
points=reference_points,
|
||||||
total_points=len(reference_points)
|
total_points=len(reference_points)
|
||||||
@ -731,16 +734,33 @@ class BaseProcessor:
|
|||||||
|
|
||||||
def get_full_state(self) -> dict[str, Any]:
|
def get_full_state(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Return complete processor state including sweep history.
|
Return complete processor state including sweep history and active reference.
|
||||||
|
|
||||||
This should be called explicitly when needed (e.g., for export/download),
|
This should be called explicitly when needed (e.g., for export/download),
|
||||||
not on every broadcast.
|
not on every broadcast.
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
# Get active reference info from the latest history entry
|
||||||
|
active_reference = None
|
||||||
|
if self._sweep_history:
|
||||||
|
latest_entry = self._sweep_history[-1]
|
||||||
|
reference_info = latest_entry.get("reference_info")
|
||||||
|
reference_data = latest_entry.get("reference_data")
|
||||||
|
|
||||||
|
if reference_info is not None:
|
||||||
|
# Export full ReferenceInfo metadata using its to_dict() method
|
||||||
|
active_reference = reference_info.to_dict()
|
||||||
|
# Also include the sweep data points
|
||||||
|
if reference_data is not None:
|
||||||
|
active_reference["points"] = self._points_to_list(getattr(reference_data, "points", []))
|
||||||
|
active_reference["total_points"] = getattr(reference_data, "total_points", 0)
|
||||||
|
active_reference["sweep_timestamp"] = getattr(reference_data, "timestamp", None)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"processor_id": self.processor_id,
|
"processor_id": self.processor_id,
|
||||||
"config": self._config.copy(),
|
"config": self._config.copy(),
|
||||||
"history_count": len(self._sweep_history),
|
"history_count": len(self._sweep_history),
|
||||||
"max_history": self._max_history,
|
"max_history": self._max_history,
|
||||||
"sweep_history": self.export_history_data(),
|
"sweep_history": self.export_history_data(),
|
||||||
|
"active_reference": active_reference,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"open_air": false,
|
"open_air": true,
|
||||||
"axis": "phase",
|
"axis": "abs",
|
||||||
"cut": 0.266,
|
"cut": 0.266,
|
||||||
"max": 2.3,
|
"max": 2.3,
|
||||||
"gain": 0.3,
|
"gain": 0.3,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"y_min": -80,
|
"y_min": -45,
|
||||||
"y_max": 40,
|
"y_max": 40,
|
||||||
"autoscale": true,
|
"autoscale": true,
|
||||||
"show_magnitude": true,
|
"show_magnitude": true,
|
||||||
"show_phase": true
|
"show_phase": false
|
||||||
}
|
}
|
||||||
@ -138,12 +138,11 @@ class BScanProcessor(BaseProcessor):
|
|||||||
super().update_config(updates)
|
super().update_config(updates)
|
||||||
|
|
||||||
def _clear_plot_history(self) -> None:
|
def _clear_plot_history(self) -> None:
|
||||||
"""Clear the accumulated plot history."""
|
"""Clear the accumulated plot and sweep history completely."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
latest = self._sweep_history[-1]
|
|
||||||
self._sweep_history.clear()
|
self._sweep_history.clear()
|
||||||
self._sweep_history.append(latest)
|
self._plot_history.clear()
|
||||||
logger.info("Plot history cleared", processor_id=self.processor_id)
|
logger.info("Plot and sweep history cleared completely", processor_id=self.processor_id)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Processing
|
# Processing
|
||||||
@ -206,7 +205,6 @@ class BScanProcessor(BaseProcessor):
|
|||||||
plot_record = {
|
plot_record = {
|
||||||
"time_domain_data": analysis["time_data"].tolist(),
|
"time_domain_data": analysis["time_data"].tolist(),
|
||||||
"distance_data": analysis["distance"].tolist(),
|
"distance_data": analysis["distance"].tolist(),
|
||||||
"sweep_number": sweep_data.sweep_number,
|
|
||||||
"timestamp": sweep_data.timestamp,
|
"timestamp": sweep_data.timestamp,
|
||||||
"frequency_range": analysis["freq_range"],
|
"frequency_range": analysis["freq_range"],
|
||||||
}
|
}
|
||||||
@ -220,7 +218,8 @@ class BScanProcessor(BaseProcessor):
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
all_time_domain = [record["time_domain_data"] for record in self._plot_history]
|
all_time_domain = [record["time_domain_data"] for record in self._plot_history]
|
||||||
all_distance = [record["distance_data"] for record in self._plot_history]
|
all_distance = [record["distance_data"] for record in self._plot_history]
|
||||||
all_sweep_numbers = [record["sweep_number"] for record in self._plot_history]
|
# Generate sequential sweep numbers starting from 1
|
||||||
|
all_sweep_numbers = list(range(1, len(self._plot_history) + 1))
|
||||||
all_timestamps = [record["timestamp"] for record in self._plot_history]
|
all_timestamps = [record["timestamp"] for record in self._plot_history]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -292,13 +291,12 @@ class BScanProcessor(BaseProcessor):
|
|||||||
y_coords: list[float] = []
|
y_coords: list[float] = []
|
||||||
z_values: list[float] = []
|
z_values: list[float] = []
|
||||||
|
|
||||||
for item in history:
|
for sweep_index, item in enumerate(history, start=1):
|
||||||
sweep_num = item["sweep_number"]
|
|
||||||
depths = item["distance_data"]
|
depths = item["distance_data"]
|
||||||
amps = item["time_domain_data"]
|
amps = item["time_domain_data"]
|
||||||
|
|
||||||
for d, a in zip(depths, amps, strict=False):
|
for d, a in zip(depths, amps, strict=False):
|
||||||
x_coords.append(sweep_num)
|
x_coords.append(sweep_index)
|
||||||
y_coords.append(d)
|
y_coords.append(d)
|
||||||
z_values.append(a)
|
z_values.append(a)
|
||||||
|
|
||||||
@ -368,8 +366,32 @@ class BScanProcessor(BaseProcessor):
|
|||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if not self._sweep_history:
|
if not self._sweep_history:
|
||||||
logger.debug("Recalculate skipped; sweep history empty")
|
logger.debug("Recalculate with empty history; returning empty result")
|
||||||
return None
|
# Return empty result with proper structure
|
||||||
|
empty_data = {
|
||||||
|
"time_domain_data": [],
|
||||||
|
"distance_data": [],
|
||||||
|
"frequency_range": [0.0, 0.0],
|
||||||
|
"reference_used": False,
|
||||||
|
"axis_type": self._config["axis"],
|
||||||
|
"points_processed": 0,
|
||||||
|
"plot_history_count": 0,
|
||||||
|
"all_time_domain_data": [],
|
||||||
|
"all_distance_data": [],
|
||||||
|
"all_sweep_numbers": [],
|
||||||
|
"all_timestamps": [],
|
||||||
|
}
|
||||||
|
plotly_conf = self.generate_plotly_config(empty_data, {})
|
||||||
|
ui_params = self.get_ui_parameters()
|
||||||
|
|
||||||
|
return ProcessedResult(
|
||||||
|
processor_id=self.processor_id,
|
||||||
|
timestamp=datetime.now().timestamp(),
|
||||||
|
data=empty_data,
|
||||||
|
plotly_config=plotly_conf,
|
||||||
|
ui_parameters=ui_params,
|
||||||
|
metadata=self._get_metadata(),
|
||||||
|
)
|
||||||
|
|
||||||
# Clear existing plot history to rebuild from scratch
|
# Clear existing plot history to rebuild from scratch
|
||||||
self._plot_history.clear()
|
self._plot_history.clear()
|
||||||
@ -657,3 +679,22 @@ class BScanProcessor(BaseProcessor):
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error("Depth processing failed", error=repr(exc))
|
logger.error("Depth processing failed", error=repr(exc))
|
||||||
return depth_m, response
|
return depth_m, response
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# State export override
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_full_state(self) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Return complete processor state including BScan-specific data.
|
||||||
|
|
||||||
|
Extends base implementation with plot history count.
|
||||||
|
"""
|
||||||
|
# Get base state (includes active_reference)
|
||||||
|
state = super().get_full_state()
|
||||||
|
|
||||||
|
# Add BScan-specific information
|
||||||
|
with self._lock:
|
||||||
|
state["plot_history_count"] = len(self._plot_history)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|||||||
@ -59,6 +59,20 @@ class MagnitudeProcessor(BaseProcessor):
|
|||||||
logger.warning("Calibrated sweep contains zero points")
|
logger.warning("Calibrated sweep contains zero points")
|
||||||
return {"error": "Empty calibrated sweep"}
|
return {"error": "Empty calibrated sweep"}
|
||||||
|
|
||||||
|
# Apply open air reference subtraction if enabled
|
||||||
|
reference_data = None
|
||||||
|
if self._config["open_air"] and self._sweep_history:
|
||||||
|
latest_history = self._sweep_history[-1]
|
||||||
|
reference_data = latest_history.get("reference_data")
|
||||||
|
if reference_data is None:
|
||||||
|
logger.warning("Open air subtraction cannot be done: reference_data is None")
|
||||||
|
self._config["open_air"] = False
|
||||||
|
|
||||||
|
if self._config["open_air"] and reference_data is not None:
|
||||||
|
reference_points = getattr(reference_data, "points", None)
|
||||||
|
if reference_points:
|
||||||
|
points = self._subtract_reference(points, reference_points)
|
||||||
|
|
||||||
# Frequency axis from VNA config (defaults if not provided)
|
# Frequency axis from VNA config (defaults if not provided)
|
||||||
start_freq = float(vna_config.get("start_freq", 100e6))
|
start_freq = float(vna_config.get("start_freq", 100e6))
|
||||||
stop_freq = float(vna_config.get("stop_freq", 8.8e9))
|
stop_freq = float(vna_config.get("stop_freq", 8.8e9))
|
||||||
@ -228,6 +242,12 @@ class MagnitudeProcessor(BaseProcessor):
|
|||||||
type="toggle",
|
type="toggle",
|
||||||
value=self._config.get("show_phase", False),
|
value=self._config.get("show_phase", False),
|
||||||
),
|
),
|
||||||
|
UIParameter(
|
||||||
|
name="open_air",
|
||||||
|
label="Вычесть Open Air",
|
||||||
|
type="toggle",
|
||||||
|
value=self._config.get("open_air", False),
|
||||||
|
),
|
||||||
UIParameter(
|
UIParameter(
|
||||||
name="autoscale",
|
name="autoscale",
|
||||||
label="Автомасштаб оси Y",
|
label="Автомасштаб оси Y",
|
||||||
@ -258,6 +278,7 @@ class MagnitudeProcessor(BaseProcessor):
|
|||||||
"autoscale": False,
|
"autoscale": False,
|
||||||
"show_magnitude": True,
|
"show_magnitude": True,
|
||||||
"show_phase": False,
|
"show_phase": False,
|
||||||
|
"open_air": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
def update_config(self, updates: dict[str, Any]) -> None:
|
def update_config(self, updates: dict[str, Any]) -> None:
|
||||||
@ -287,3 +308,35 @@ class MagnitudeProcessor(BaseProcessor):
|
|||||||
logger.info("Adjusted y_max to maintain y_max > y_min",
|
logger.info("Adjusted y_max to maintain y_max > y_min",
|
||||||
y_min=self._config["y_min"],
|
y_min=self._config["y_min"],
|
||||||
y_max=self._config["y_max"])
|
y_max=self._config["y_max"])
|
||||||
|
|
||||||
|
def _subtract_reference(
|
||||||
|
self,
|
||||||
|
signal: list[tuple[float, float]],
|
||||||
|
reference: list[tuple[float, float]]
|
||||||
|
) -> list[tuple[float, float]]:
|
||||||
|
"""
|
||||||
|
Subtract reference from signal (complex subtraction).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
signal: List of (real, imag) tuples
|
||||||
|
reference: List of (real, imag) tuples
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (real, imag) tuples after subtraction
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
n = min(len(signal), len(reference))
|
||||||
|
if n == 0:
|
||||||
|
return signal
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for i in range(n):
|
||||||
|
sig_real, sig_imag = signal[i]
|
||||||
|
ref_real, ref_imag = reference[i]
|
||||||
|
result.append((sig_real - ref_real, sig_imag - ref_imag))
|
||||||
|
|
||||||
|
logger.debug("Reference subtraction completed", points=n)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Reference subtraction failed", error=repr(exc))
|
||||||
|
return signal
|
||||||
|
|||||||
@ -100,7 +100,7 @@ class ProcessorManager:
|
|||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
# Main processing actions
|
# Main processing actions
|
||||||
# --------------------------------------------------------------------- #
|
# --------------------------------------------------------------------- #
|
||||||
def process_sweep(self, sweep_data: SweepData, calibrated_data: Any, vna_config: ConfigPreset | None, reference_data: Any = None) -> dict[str, ProcessedResult]:
|
def process_sweep(self, sweep_data: SweepData, calibrated_data: Any, vna_config: ConfigPreset | None, reference_data: Any = None, reference_info: Any = None) -> dict[str, ProcessedResult]:
|
||||||
"""
|
"""
|
||||||
Feed a sweep into all processors and dispatch results to callbacks.
|
Feed a sweep into all processors and dispatch results to callbacks.
|
||||||
|
|
||||||
@ -114,7 +114,7 @@ class ProcessorManager:
|
|||||||
|
|
||||||
for processor_id, processor in processors_items:
|
for processor_id, processor in processors_items:
|
||||||
try:
|
try:
|
||||||
result = processor.add_sweep_data(sweep_data, calibrated_data, vna_config, reference_data)
|
result = processor.add_sweep_data(sweep_data, calibrated_data, vna_config, reference_data, reference_info)
|
||||||
if result:
|
if result:
|
||||||
results[processor_id] = result
|
results[processor_id] = result
|
||||||
for cb in callbacks:
|
for cb in callbacks:
|
||||||
@ -280,7 +280,8 @@ class ProcessorManager:
|
|||||||
calibrated = self._apply_calibration(latest)
|
calibrated = self._apply_calibration(latest)
|
||||||
vna_cfg = self.settings_manager.get_current_preset()
|
vna_cfg = self.settings_manager.get_current_preset()
|
||||||
reference_data = self._apply_calibration(self.settings_manager.get_current_reference_sweep(vna_cfg))
|
reference_data = self._apply_calibration(self.settings_manager.get_current_reference_sweep(vna_cfg))
|
||||||
self.process_sweep(latest, calibrated, vna_cfg, reference_data)
|
reference_info = self.settings_manager.get_current_reference(vna_cfg)
|
||||||
|
self.process_sweep(latest, calibrated, vna_cfg, reference_data, reference_info)
|
||||||
self._last_processed_sweep = latest.sweep_number
|
self._last_processed_sweep = latest.sweep_number
|
||||||
|
|
||||||
# Light-duty polling to reduce wakeups
|
# Light-duty polling to reduce wakeups
|
||||||
|
|||||||
@ -287,12 +287,26 @@ class VNASettingsManager:
|
|||||||
if not current_preset:
|
if not current_preset:
|
||||||
raise ValueError("No current preset available")
|
raise ValueError("No current preset available")
|
||||||
|
|
||||||
|
# Get current calibration info to store in metadata
|
||||||
|
current_calibration = self.get_current_calibration()
|
||||||
|
calibration_info = None
|
||||||
|
if current_calibration:
|
||||||
|
calibration_info = {
|
||||||
|
"calibration_name": current_calibration.name,
|
||||||
|
"preset_filename": current_calibration.preset.filename
|
||||||
|
}
|
||||||
|
|
||||||
|
# Merge calibration info into metadata
|
||||||
|
merged_metadata = metadata or {}
|
||||||
|
if calibration_info:
|
||||||
|
merged_metadata["calibration"] = calibration_info
|
||||||
|
|
||||||
reference_info = self.reference_manager.create_reference(
|
reference_info = self.reference_manager.create_reference(
|
||||||
name=reference_name,
|
name=reference_name,
|
||||||
preset=current_preset,
|
preset=current_preset,
|
||||||
sweep_data=latest,
|
sweep_data=latest,
|
||||||
description=description,
|
description=description,
|
||||||
metadata=metadata or {}
|
metadata=merged_metadata
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
BIN
vna_system/core/sound_sample.mp3
Normal file
BIN
vna_system/core/sound_sample.mp3
Normal file
Binary file not shown.
@ -380,7 +380,8 @@ export class ChartManager {
|
|||||||
plotly_config: safeClone(current_data.plotly_config),
|
plotly_config: safeClone(current_data.plotly_config),
|
||||||
timestamp: current_data.timestamp
|
timestamp: current_data.timestamp
|
||||||
} : null,
|
} : null,
|
||||||
sweep_history: state.sweep_history || []
|
sweep_history: state.sweep_history || [],
|
||||||
|
active_reference: state.active_reference || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -98,6 +98,7 @@ export class SettingsManager {
|
|||||||
currentReferenceName: document.getElementById('currentReferenceName'),
|
currentReferenceName: document.getElementById('currentReferenceName'),
|
||||||
currentReferenceTimestamp: document.getElementById('currentReferenceTimestamp'),
|
currentReferenceTimestamp: document.getElementById('currentReferenceTimestamp'),
|
||||||
currentReferenceDescription: document.getElementById('currentReferenceDescription'),
|
currentReferenceDescription: document.getElementById('currentReferenceDescription'),
|
||||||
|
currentReferenceCalibration: document.getElementById('currentReferenceCalibration'),
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
presetCount: document.getElementById('presetCount'),
|
presetCount: document.getElementById('presetCount'),
|
||||||
|
|||||||
@ -203,6 +203,18 @@ export class ReferenceManager {
|
|||||||
this.elements.currentReferenceDescription.textContent = reference.description || '—';
|
this.elements.currentReferenceDescription.textContent = reference.description || '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show calibration info if available
|
||||||
|
if (this.elements.currentReferenceCalibration) {
|
||||||
|
const calibrationInfo = reference.metadata?.calibration;
|
||||||
|
if (calibrationInfo && calibrationInfo.calibration_name) {
|
||||||
|
this.elements.currentReferenceCalibration.textContent = calibrationInfo.calibration_name;
|
||||||
|
this.elements.currentReferenceCalibration.parentElement.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
this.elements.currentReferenceCalibration.textContent = '—';
|
||||||
|
this.elements.currentReferenceCalibration.parentElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.onReferenceUpdated?.(reference);
|
this.onReferenceUpdated?.(reference);
|
||||||
this.updateButtons();
|
this.updateButtons();
|
||||||
}
|
}
|
||||||
@ -239,8 +251,20 @@ export class ReferenceManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleReferenceChange() {
|
async handleReferenceChange() {
|
||||||
this.updateButtons();
|
this.updateButtons();
|
||||||
|
|
||||||
|
// Show info for selected reference immediately
|
||||||
|
const selectedName = this.elements.referenceDropdown?.value;
|
||||||
|
if (selectedName) {
|
||||||
|
const selectedRef = this.availableReferences.find(r => r.name === selectedName);
|
||||||
|
if (selectedRef) {
|
||||||
|
this.updateInfo(selectedRef);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no selection, show current reference info
|
||||||
|
this.updateInfo(this.currentReference);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handlePreviewReference() {
|
async handlePreviewReference() {
|
||||||
|
|||||||
@ -316,6 +316,10 @@
|
|||||||
<span class="reference-details__label">Описание:</span>
|
<span class="reference-details__label">Описание:</span>
|
||||||
<span class="reference-details__value" id="currentReferenceDescription">—</span>
|
<span class="reference-details__value" id="currentReferenceDescription">—</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="reference-details__row" style="display: none;">
|
||||||
|
<span class="reference-details__label">Калибровка:</span>
|
||||||
|
<span class="reference-details__value" id="currentReferenceCalibration">—</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user