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.acquisition.port_manager import VNAPortLocator
|
||||
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
|
||||
|
||||
# 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__)
|
||||
|
||||
|
||||
@ -30,6 +37,16 @@ class VNADataAcquisition:
|
||||
self.vna_port_locator = VNAPortLocator()
|
||||
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
|
||||
self._running: bool = False
|
||||
self._thread: threading.Thread | None = None
|
||||
@ -45,7 +62,7 @@ class VNADataAcquisition:
|
||||
self._collected_rx_payloads: list[bytes] = []
|
||||
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:
|
||||
"""Get the path to the current binary input file from JSON config."""
|
||||
@ -175,8 +192,55 @@ class VNADataAcquisition:
|
||||
# --------------------------------------------------------------------- #
|
||||
# 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:
|
||||
"""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():
|
||||
try:
|
||||
# Honor pause
|
||||
@ -305,6 +369,9 @@ class VNADataAcquisition:
|
||||
expected=cfg.EXPECTED_POINTS_PER_SWEEP,
|
||||
actual=len(all_points),
|
||||
)
|
||||
|
||||
# Play sweep notification sound
|
||||
self._sound_player.play()
|
||||
else:
|
||||
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
|
||||
# ------------------------------
|
||||
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.
|
||||
|
||||
@ -73,13 +73,16 @@ class SweepBuffer:
|
||||
----------
|
||||
points:
|
||||
Sequence of (real, imag) tuples representing a sweep.
|
||||
timestamp:
|
||||
Optional UNIX timestamp. If None, current time is used.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The assigned sweep number for the newly added sweep.
|
||||
"""
|
||||
timestamp = time.time()
|
||||
if timestamp is None:
|
||||
timestamp = time.time()
|
||||
with self._lock:
|
||||
self._sweep_counter += 1
|
||||
sweep = SweepData(
|
||||
|
||||
@ -32,6 +32,13 @@ TX_CHUNK_SIZE = 64 * 1024
|
||||
VNA_VID = 0x0483 # STMicroelectronics
|
||||
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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@ -299,7 +299,7 @@ class BaseProcessor:
|
||||
# --------------------------------------------------------------------- #
|
||||
# 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.
|
||||
|
||||
@ -313,6 +313,8 @@ class BaseProcessor:
|
||||
Snapshot of VNA settings (dataclass or pydantic model supported).
|
||||
reference_data:
|
||||
Open air reference sweep data for background subtraction/normalization.
|
||||
reference_info:
|
||||
ReferenceInfo object with metadata (name, description, etc.) about the reference.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@ -326,6 +328,7 @@ class BaseProcessor:
|
||||
"calibrated_data": calibrated_data,
|
||||
"vna_config": self._snapshot_vna_config(vna_config),
|
||||
"reference_data": reference_data,
|
||||
"reference_info": reference_info,
|
||||
"timestamp": datetime.now().timestamp(),
|
||||
}
|
||||
)
|
||||
@ -614,7 +617,6 @@ class BaseProcessor:
|
||||
reference_data = entry.get("reference_data")
|
||||
|
||||
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,
|
||||
"sweep_points": self._points_to_list(getattr(sweep_data, "points", [])),
|
||||
"calibrated_points": self._points_to_list(getattr(calibrated_data, "points", [])),
|
||||
@ -639,28 +641,29 @@ class BaseProcessor:
|
||||
with self._lock:
|
||||
self._sweep_history.clear()
|
||||
|
||||
for entry in history_data:
|
||||
for idx, entry in enumerate(history_data):
|
||||
sweep_points = entry.get("sweep_points", [])
|
||||
calibrated_points = entry.get("calibrated_points", [])
|
||||
reference_points = entry.get("reference_points", [])
|
||||
|
||||
# Reconstruct SweepData objects
|
||||
# Use sequential index as sweep_number since it's not stored
|
||||
sweep_data = SweepData(
|
||||
sweep_number=entry.get("sweep_number", 0),
|
||||
sweep_number=idx,
|
||||
timestamp=entry.get("timestamp", 0.0),
|
||||
points=sweep_points,
|
||||
total_points=len(sweep_points)
|
||||
) if sweep_points else None
|
||||
|
||||
calibrated_data = SweepData(
|
||||
sweep_number=entry.get("sweep_number", 0),
|
||||
sweep_number=idx,
|
||||
timestamp=entry.get("timestamp", 0.0),
|
||||
points=calibrated_points,
|
||||
total_points=len(calibrated_points)
|
||||
) if calibrated_points else None
|
||||
|
||||
reference_data = SweepData(
|
||||
sweep_number=entry.get("sweep_number", 0),
|
||||
sweep_number=idx,
|
||||
timestamp=entry.get("timestamp", 0.0),
|
||||
points=reference_points,
|
||||
total_points=len(reference_points)
|
||||
@ -731,16 +734,33 @@ class BaseProcessor:
|
||||
|
||||
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),
|
||||
not on every broadcast.
|
||||
"""
|
||||
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 {
|
||||
"processor_id": self.processor_id,
|
||||
"config": self._config.copy(),
|
||||
"history_count": len(self._sweep_history),
|
||||
"max_history": self._max_history,
|
||||
"sweep_history": self.export_history_data(),
|
||||
"active_reference": active_reference,
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"open_air": false,
|
||||
"axis": "phase",
|
||||
"open_air": true,
|
||||
"axis": "abs",
|
||||
"cut": 0.266,
|
||||
"max": 2.3,
|
||||
"gain": 0.3,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"y_min": -80,
|
||||
"y_min": -45,
|
||||
"y_max": 40,
|
||||
"autoscale": true,
|
||||
"show_magnitude": true,
|
||||
"show_phase": true
|
||||
"show_phase": false
|
||||
}
|
||||
@ -138,12 +138,11 @@ class BScanProcessor(BaseProcessor):
|
||||
super().update_config(updates)
|
||||
|
||||
def _clear_plot_history(self) -> None:
|
||||
"""Clear the accumulated plot history."""
|
||||
"""Clear the accumulated plot and sweep history completely."""
|
||||
with self._lock:
|
||||
latest = self._sweep_history[-1]
|
||||
self._sweep_history.clear()
|
||||
self._sweep_history.append(latest)
|
||||
logger.info("Plot history cleared", processor_id=self.processor_id)
|
||||
self._plot_history.clear()
|
||||
logger.info("Plot and sweep history cleared completely", processor_id=self.processor_id)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Processing
|
||||
@ -206,7 +205,6 @@ class BScanProcessor(BaseProcessor):
|
||||
plot_record = {
|
||||
"time_domain_data": analysis["time_data"].tolist(),
|
||||
"distance_data": analysis["distance"].tolist(),
|
||||
"sweep_number": sweep_data.sweep_number,
|
||||
"timestamp": sweep_data.timestamp,
|
||||
"frequency_range": analysis["freq_range"],
|
||||
}
|
||||
@ -220,7 +218,8 @@ class BScanProcessor(BaseProcessor):
|
||||
with self._lock:
|
||||
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_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]
|
||||
|
||||
return {
|
||||
@ -292,13 +291,12 @@ class BScanProcessor(BaseProcessor):
|
||||
y_coords: list[float] = []
|
||||
z_values: list[float] = []
|
||||
|
||||
for item in history:
|
||||
sweep_num = item["sweep_number"]
|
||||
for sweep_index, item in enumerate(history, start=1):
|
||||
depths = item["distance_data"]
|
||||
amps = item["time_domain_data"]
|
||||
|
||||
for d, a in zip(depths, amps, strict=False):
|
||||
x_coords.append(sweep_num)
|
||||
x_coords.append(sweep_index)
|
||||
y_coords.append(d)
|
||||
z_values.append(a)
|
||||
|
||||
@ -368,8 +366,32 @@ class BScanProcessor(BaseProcessor):
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._sweep_history:
|
||||
logger.debug("Recalculate skipped; sweep history empty")
|
||||
return None
|
||||
logger.debug("Recalculate with empty history; returning empty result")
|
||||
# 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
|
||||
self._plot_history.clear()
|
||||
@ -657,3 +679,22 @@ class BScanProcessor(BaseProcessor):
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Depth processing failed", error=repr(exc))
|
||||
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")
|
||||
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)
|
||||
start_freq = float(vna_config.get("start_freq", 100e6))
|
||||
stop_freq = float(vna_config.get("stop_freq", 8.8e9))
|
||||
@ -228,6 +242,12 @@ class MagnitudeProcessor(BaseProcessor):
|
||||
type="toggle",
|
||||
value=self._config.get("show_phase", False),
|
||||
),
|
||||
UIParameter(
|
||||
name="open_air",
|
||||
label="Вычесть Open Air",
|
||||
type="toggle",
|
||||
value=self._config.get("open_air", False),
|
||||
),
|
||||
UIParameter(
|
||||
name="autoscale",
|
||||
label="Автомасштаб оси Y",
|
||||
@ -258,6 +278,7 @@ class MagnitudeProcessor(BaseProcessor):
|
||||
"autoscale": False,
|
||||
"show_magnitude": True,
|
||||
"show_phase": False,
|
||||
"open_air": False,
|
||||
}
|
||||
|
||||
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",
|
||||
y_min=self._config["y_min"],
|
||||
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
|
||||
# --------------------------------------------------------------------- #
|
||||
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.
|
||||
|
||||
@ -114,7 +114,7 @@ class ProcessorManager:
|
||||
|
||||
for processor_id, processor in processors_items:
|
||||
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:
|
||||
results[processor_id] = result
|
||||
for cb in callbacks:
|
||||
@ -280,7 +280,8 @@ class ProcessorManager:
|
||||
calibrated = self._apply_calibration(latest)
|
||||
vna_cfg = self.settings_manager.get_current_preset()
|
||||
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
|
||||
|
||||
# Light-duty polling to reduce wakeups
|
||||
|
||||
@ -287,12 +287,26 @@ class VNASettingsManager:
|
||||
if not current_preset:
|
||||
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(
|
||||
name=reference_name,
|
||||
preset=current_preset,
|
||||
sweep_data=latest,
|
||||
description=description,
|
||||
metadata=metadata or {}
|
||||
metadata=merged_metadata
|
||||
)
|
||||
|
||||
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),
|
||||
timestamp: current_data.timestamp
|
||||
} : 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'),
|
||||
currentReferenceTimestamp: document.getElementById('currentReferenceTimestamp'),
|
||||
currentReferenceDescription: document.getElementById('currentReferenceDescription'),
|
||||
currentReferenceCalibration: document.getElementById('currentReferenceCalibration'),
|
||||
|
||||
// Status
|
||||
presetCount: document.getElementById('presetCount'),
|
||||
|
||||
@ -203,6 +203,18 @@ export class ReferenceManager {
|
||||
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.updateButtons();
|
||||
}
|
||||
@ -239,8 +251,20 @@ export class ReferenceManager {
|
||||
);
|
||||
}
|
||||
|
||||
handleReferenceChange() {
|
||||
async handleReferenceChange() {
|
||||
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() {
|
||||
|
||||
@ -316,6 +316,10 @@
|
||||
<span class="reference-details__label">Описание:</span>
|
||||
<span class="reference-details__value" id="currentReferenceDescription">—</span>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user