added processors and fixed errors

This commit is contained in:
Ayzen
2025-09-25 17:38:53 +03:00
parent 8f460471be
commit cdf48fd3e0
58 changed files with 19704 additions and 2155 deletions

View File

@ -0,0 +1,88 @@
from fastapi import APIRouter, HTTPException
import vna_system.core.singletons as singletons
router = APIRouter(prefix="/api/v1", tags=["acquisition"])
@router.get("/acquisition/status")
async def get_acquisition_status():
"""Get current acquisition status."""
acquisition = singletons.vna_data_acquisition_instance
return {
"running": acquisition.is_running,
"paused": acquisition.is_paused,
"continuous_mode": acquisition.is_continuous_mode,
"sweep_count": acquisition._sweep_buffer._sweep_counter if hasattr(acquisition._sweep_buffer, '_sweep_counter') else 0
}
@router.post("/acquisition/start")
async def start_acquisition():
"""Start data acquisition."""
try:
acquisition = singletons.vna_data_acquisition_instance
if not acquisition.is_running:
# Start thread if not running
acquisition.start()
# Set to continuous mode (also resumes if paused)
acquisition.set_continuous_mode(True)
return {"success": True, "message": "Acquisition started"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/acquisition/stop")
async def stop_acquisition():
"""Stop/pause data acquisition."""
try:
acquisition = singletons.vna_data_acquisition_instance
if not acquisition.is_running:
return {"success": True, "message": "Acquisition already stopped"}
# Just pause instead of full stop - keeps thread alive for restart
acquisition.pause()
return {"success": True, "message": "Acquisition stopped"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/acquisition/single-sweep")
async def trigger_single_sweep():
"""Trigger a single sweep. Automatically starts acquisition if needed."""
try:
acquisition = singletons.vna_data_acquisition_instance
if not acquisition.is_running:
# Start acquisition if not running
acquisition.start()
acquisition.trigger_single_sweep()
return {"success": True, "message": "Single sweep triggered"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/acquisition/latest-sweep")
async def get_latest_sweep():
"""Get the latest sweep data."""
try:
acquisition = singletons.vna_data_acquisition_instance
latest_sweep = acquisition._sweep_buffer.get_latest_sweep()
if not latest_sweep:
return {"sweep": None, "message": "No sweep data available"}
return {
"sweep": {
"sweep_number": latest_sweep.sweep_number,
"timestamp": latest_sweep.timestamp,
"total_points": latest_sweep.total_points,
"points": latest_sweep.points[:10] if len(latest_sweep.points) > 10 else latest_sweep.points # Limit for API response
},
"message": f"Latest sweep #{latest_sweep.sweep_number} with {latest_sweep.total_points} points"
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -15,24 +15,3 @@ router = APIRouter(prefix="/api/v1", tags=["health"])
async def root():
"""Root endpoint."""
return {"message": "VNA System API", "version": "1.0.0"}
@router.get("/health")
async def health_check():
"""Health check endpoint."""
return {
"status": "healthy",
"acquisition_running": singletons.vna_data_acquisition_instance.is_running,
"processing_running": singletons.processing_manager.is_running,
"processing_stats": singletons.processing_manager.get_processing_stats()
}
@router.get("/device-status")
async def device_status():
"""Get device connection status and details."""
acquisition = singletons.vna_data_acquisition_instance
return {
"acquisition_running": acquisition.is_running,
}

View File

@ -1,27 +0,0 @@
from fastapi import APIRouter, HTTPException
import vna_system.core.singletons as singletons
router = APIRouter(prefix="/api/v1", tags=["processing"])
@router.get("/stats")
async def get_stats():
"""Get processing statistics."""
return singletons.processing_manager.get_processing_stats()
@router.get("/history")
async def get_history(processor_name: str | None = None, limit: int = 10):
"""Get processing results history."""
results = singletons.processing_manager.get_latest_results(processor_name)[-limit:]
return [result.to_dict() for result in results]
@router.get("/sweep/{sweep_number}")
async def get_sweep(sweep_number: int, processor_name: str | None = None):
"""Get results for specific sweep."""
results = singletons.processing_manager.get_result_by_sweep(sweep_number, processor_name)
if not results:
raise HTTPException(status_code=404, detail=f"No results found for sweep {sweep_number}")
return [result.to_dict() for result in results]

View File

@ -12,9 +12,7 @@ from fastapi.staticfiles import StaticFiles
from pathlib import Path
import vna_system.core.singletons as singletons
from vna_system.core.processing.sweep_processor import SweepProcessingManager
from vna_system.core.processing.websocket_handler import WebSocketManager
from vna_system.api.endpoints import health, processing, settings, web_ui
from vna_system.api.endpoints import health, settings, web_ui, acquisition
from vna_system.api.websockets import processing as ws_processing
@ -64,14 +62,14 @@ async def lifespan(app: FastAPI):
log_level = config.get("logging", {}).get("level", "INFO")
logging.getLogger().setLevel(getattr(logging, log_level))
# Start acquisition
# Start acquisition
logger.info("Starting data acquisition...")
# Connect processing to acquisition
singletons.processing_manager.set_sweep_buffer(singletons.vna_data_acquisition_instance.sweep_buffer)
singletons.vna_data_acquisition_instance.start()
singletons.processing_manager.start()
logger.info("Sweep processing started")
# Initialize processor system
logger.info("Starting processor system...")
singletons.processor_manager.start_processing()
logger.info(f"Processor system started with processors: {singletons.processor_manager.list_processors()}")
logger.info("VNA API Server started successfully")
@ -84,11 +82,11 @@ async def lifespan(app: FastAPI):
# Shutdown
logger.info("Shutting down VNA API Server...")
if singletons.processing_manager:
singletons.processing_manager.stop()
logger.info("Processing stopped")
if singletons.processor_manager:
singletons.processor_manager.stop_processing()
logger.info("Processor system stopped")
if singletons.vna_data_acquisition_instance and singletons.vna_data_acquisition_instance.is_running:
if singletons.vna_data_acquisition_instance and singletons.vna_data_acquisition_instance._running:
singletons.vna_data_acquisition_instance.stop()
logger.info("Acquisition stopped")
@ -116,7 +114,8 @@ else:
# Include routers
app.include_router(web_ui.router) # Web UI should be first for root path
app.include_router(health.router)
app.include_router(processing.router)
# app.include_router(processing.router)
app.include_router(acquisition.router)
app.include_router(settings.router)
app.include_router(ws_processing.router)

View File

@ -12,5 +12,4 @@ router = APIRouter()
@router.websocket("/ws/processing")
async def processing_websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time processing data streaming."""
await singletons.websocket_manager.handle_websocket(websocket)
await singletons.processor_websocket_handler.handle_websocket_connection(websocket)

View File

@ -1 +1 @@
config_inputs/s11_start100_stop8800_points1000_bw1khz.bin
config_inputs/s21_start100_stop8800_points1000_bw1khz.bin

View File

@ -1 +0,0 @@
s11_start100_stop8800_points1000_bw1khz/tuncTuncTuncSahur

View File

@ -0,0 +1,18 @@
{
"preset": {
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
"mode": "s11",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "SRGDFDFG",
"standards": [
"open",
"short",
"load"
],
"created_timestamp": "2025-09-25T16:15:09.918600",
"is_complete": true
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,16 @@
{
"preset": {
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
"mode": "s11",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "SRGDFDFG",
"standard": "load",
"sweep_number": 224,
"sweep_timestamp": 1758806106.5021908,
"created_timestamp": "2025-09-25T16:15:09.918522",
"total_points": 1000
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,16 @@
{
"preset": {
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
"mode": "s11",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "SRGDFDFG",
"standard": "open",
"sweep_number": 224,
"sweep_timestamp": 1758806106.5021908,
"created_timestamp": "2025-09-25T16:15:09.913979",
"total_points": 1000
}

View File

@ -0,0 +1,16 @@
{
"preset": {
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
"mode": "s11",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "SRGDFDFG",
"standard": "short",
"sweep_number": 224,
"sweep_timestamp": 1758806106.5021908,
"created_timestamp": "2025-09-25T16:15:09.916088",
"total_points": 1000
}

View File

@ -0,0 +1,16 @@
{
"preset": {
"filename": "s21_start100_stop8800_points1000_bw1khz.bin",
"mode": "s21",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "bambambum",
"standards": [
"through"
],
"created_timestamp": "2025-09-25T14:41:46.014320",
"is_complete": true
}

View File

@ -0,0 +1,16 @@
{
"preset": {
"filename": "s21_start100_stop8800_points1000_bw1khz.bin",
"mode": "s21",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "bambambum",
"standard": "through",
"sweep_number": 50,
"sweep_timestamp": 1758800474.7658994,
"created_timestamp": "2025-09-25T14:41:46.014170",
"total_points": 1000
}

View File

@ -10,7 +10,7 @@ from typing import BinaryIO, List, Tuple
import serial
from vna_system.config import config as cfg
from vna_system.core import config as cfg
from vna_system.core.acquisition.sweep_buffer import SweepBuffer
@ -31,6 +31,11 @@ class VNADataAcquisition:
self._running: bool = False
self._thread: threading.Thread | None = None
self._stop_event: threading.Event = threading.Event()
self._paused: bool = False
# Acquisition modes
self._continuous_mode: bool = True
self._single_sweep_requested: bool = False
# Sweep collection state
self._collecting: bool = False
@ -77,6 +82,54 @@ class VNADataAcquisition:
"""Return a reference to the sweep buffer."""
return self._sweep_buffer
@property
def is_paused(self) -> bool:
"""Return True if acquisition is paused."""
return self._paused
@property
def is_continuous_mode(self) -> bool:
"""Return True if in continuous sweep mode."""
return self._continuous_mode
def pause(self) -> None:
"""Pause the data acquisition."""
if not self._running:
logger.warning("Cannot pause: acquisition not running")
return
self._paused = True
logger.info("Data acquisition paused")
def set_continuous_mode(self, continuous: bool = True) -> None:
"""Set continuous or single sweep mode. Also resumes if paused."""
self._continuous_mode = continuous
# Resume acquisition if setting to continuous mode and currently paused
if continuous and self._paused:
self._paused = False
logger.info("Data acquisition resumed (continuous mode)")
mode_str = "continuous" if continuous else "single sweep"
logger.info(f"Acquisition mode set to: {mode_str}")
def trigger_single_sweep(self) -> None:
"""Trigger a single sweep. Automatically switches to single sweep mode if needed."""
if not self._running:
logger.warning("Cannot trigger single sweep: acquisition not running")
return
# Switch to single sweep mode if currently in continuous mode
if self._continuous_mode:
self.set_continuous_mode(False)
logger.info("Switched from continuous to single sweep mode")
self._single_sweep_requested = True
if self._paused:
self._paused = False
logger.info("Data acquisition resumed for single sweep")
logger.info("Single sweep triggered")
# --------------------------------------------------------------------- #
# Serial management
# --------------------------------------------------------------------- #
@ -104,6 +157,11 @@ class VNADataAcquisition:
"""Main acquisition loop executed by the background thread."""
while self._running and not self._stop_event.is_set():
try:
# Check if paused
if self._paused:
time.sleep(0.1)
continue
# Auto-detect port
self.port: str = cfg.get_vna_port()
logger.info(f"Using auto-detected port: {self.port}")
@ -114,6 +172,16 @@ class VNADataAcquisition:
buffered = io.BufferedReader(raw, buffer_size=cfg.SERIAL_BUFFER_SIZE)
self._process_sweep_data(buffered, ser)
# Handle single sweep mode
if not self._continuous_mode:
if self._single_sweep_requested:
self._single_sweep_requested = False
logger.info("Single sweep completed, pausing acquisition")
self.pause()
else:
# In single sweep mode but no sweep requested, pause
self.pause()
except Exception as exc: # noqa: BLE001
logger.error("Acquisition error: %s", exc)
time.sleep(1.0)

View File

@ -5,7 +5,7 @@ from collections import deque
from dataclasses import dataclass
from typing import List, Tuple
from vna_system.config.config import SWEEP_BUFFER_MAX_SIZE
from vna_system.core.config import SWEEP_BUFFER_MAX_SIZE
@dataclass
@ -52,9 +52,3 @@ class SweepBuffer:
"""Get the most recent sweep"""
with self._lock:
return self._buffer[-1] if self._buffer else None
def set_sweep_counter(self, sweep_number: int) -> None:
"""Set the sweep counter to continue from a specific number."""
with self._lock:
self._sweep_counter = sweep_number

View File

@ -1 +0,0 @@
"""Sweep data processing module."""

View File

@ -1,63 +0,0 @@
#!/usr/bin/env python3
"""
Base sweep processor interface and utilities.
"""
from __future__ import annotations
import abc
from typing import Any, Dict
from vna_system.core.acquisition.sweep_buffer import SweepData
class BaseSweepProcessor(abc.ABC):
"""Abstract base class for sweep data processors."""
def __init__(self, config: Dict[str, Any]) -> None:
"""Initialize processor with configuration."""
self.config = config
self.enabled = config.get("enabled", True)
@property
@abc.abstractmethod
def name(self) -> str:
"""Return processor name."""
pass
@abc.abstractmethod
def process(self, sweep: SweepData) -> ProcessingResult | None:
"""Process a sweep and return results."""
pass
def should_process(self, sweep: SweepData) -> bool:
"""Check if this processor should process the given sweep."""
return self.enabled
class ProcessingResult:
"""Result of sweep processing."""
def __init__(
self,
processor_name: str,
sweep_number: int,
data: Dict[str, Any],
plotly_figure: Dict[str, Any] | None = None,
file_path: str | None = None,
) -> None:
self.processor_name = processor_name
self.sweep_number = sweep_number
self.data = data
self.plotly_figure = plotly_figure
self.file_path = file_path
def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary for serialization."""
return {
"processor_name": self.processor_name,
"sweep_number": self.sweep_number,
"data": self.data,
"plotly_figure": self.plotly_figure,
"file_path": self.file_path,
}

View File

@ -1,20 +0,0 @@
{
"processors": {
"magnitude_plot": {
"enabled": true,
"output_dir": "./plots/magnitude",
"save_image": true,
"image_format": "png",
"width": 800,
"height": 600
}
},
"processing": {
"queue_max_size": 1000
},
"storage": {
"dir": "./processing_results",
"max_sweeps": 10000,
"sweeps_per_subdir": 1000
}
}

View File

@ -1 +0,0 @@
"""Sweep data processors."""

View File

@ -1,209 +0,0 @@
#!/usr/bin/env python3
"""
Magnitude plot processor for sweep data.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Tuple
import numpy as np
import matplotlib.pyplot as plt
import matplotlib
matplotlib.use('Agg') # Use non-interactive backend
import plotly.graph_objects as go
from vna_system.core.acquisition.sweep_buffer import SweepData
from vna_system.core.processing.base_processor import BaseSweepProcessor, ProcessingResult
from vna_system.core.processing.calibration_processor import CalibrationProcessor
import vna_system.core.singletons as singletons
class MagnitudePlotProcessor(BaseSweepProcessor):
"""Processor that creates magnitude plots from sweep data."""
def __init__(self, config: Dict[str, Any]) -> None:
super().__init__(config)
self.output_dir = Path(config.get("output_dir", "./plots"))
self.save_image = config.get("save_image", True)
self.image_format = config.get("image_format", "png")
self.width = config.get("width", 800)
self.height = config.get("height", 600)
# Calibration support
self.apply_calibration = config.get("apply_calibration", True)
self.calibration_processor = CalibrationProcessor()
# Create output directory if it doesn't exist
if self.save_image:
self.output_dir.mkdir(parents=True, exist_ok=True)
@property
def name(self) -> str:
return "magnitude_plot"
def process(self, sweep: SweepData) -> ProcessingResult | None:
"""Process sweep data and create magnitude plot."""
if not self.should_process(sweep):
return None
# Get current calibration from settings manager
current_calibration = singletons.settings_manager.get_current_calibration()
# Apply calibration if available and enabled
processed_sweep = self._apply_calibration_if_available(sweep, current_calibration)
# Extract magnitude data
magnitude_data = self._extract_magnitude_data(processed_sweep)
if not magnitude_data:
return None
# Create plotly figure for websocket/API consumption
plotly_fig = self._create_plotly_figure(sweep, magnitude_data, current_calibration is not None)
# Save image if requested (using matplotlib)
file_path = None
if self.save_image:
file_path = self._save_matplotlib_image(sweep, magnitude_data, current_calibration is not None)
# Prepare result data
result_data = {
"sweep_number": sweep.sweep_number,
"timestamp": sweep.timestamp,
"total_points": sweep.total_points,
"magnitude_stats": self._calculate_magnitude_stats(magnitude_data),
"calibration_applied": current_calibration is not None and self.apply_calibration,
"calibration_name": current_calibration.name if current_calibration else None,
}
return ProcessingResult(
processor_name=self.name,
sweep_number=sweep.sweep_number,
data=result_data,
plotly_figure=plotly_fig.to_dict(),
file_path=str(file_path) if file_path else None,
)
def _apply_calibration_if_available(self, sweep: SweepData, calibration_set) -> SweepData:
"""Apply calibration to sweep data if calibration is available and enabled."""
if not self.apply_calibration or not calibration_set:
return sweep
try:
# Apply calibration and get corrected complex array
calibrated_complex = self.calibration_processor.apply_calibration(sweep, calibration_set)
# Convert back to (real, imag) tuples for SweepData
calibrated_points = [(complex_val.real, complex_val.imag) for complex_val in calibrated_complex]
# Create new SweepData with calibrated points
return SweepData(
sweep_number=sweep.sweep_number,
timestamp=sweep.timestamp,
points=calibrated_points,
total_points=len(calibrated_points)
)
except Exception as e:
print(f"Failed to apply calibration: {e}")
return sweep
def _extract_magnitude_data(self, sweep: SweepData) -> List[Tuple[float, float]]:
"""Extract magnitude data from sweep points."""
magnitude_data = []
for i, (real, imag) in enumerate(sweep.points):
magnitude = np.sqrt(real**2 + imag**2)
magnitude_data.append((i, magnitude))
return magnitude_data
def _create_plotly_figure(self, sweep: SweepData, magnitude_data: List[Tuple[float, float]], calibrated: bool = False) -> go.Figure:
"""Create plotly figure for magnitude plot."""
indices = [point[0] for point in magnitude_data]
magnitudes = [point[1] for point in magnitude_data]
fig = go.Figure()
# Choose color based on calibration status
line_color = 'green' if calibrated else 'blue'
trace_name = 'Magnitude (Calibrated)' if calibrated else 'Magnitude (Raw)'
fig.add_trace(go.Scatter(
x=indices,
y=magnitudes,
mode='lines',
name=trace_name,
line=dict(color=line_color, width=2)
))
# Add calibration indicator to title
title_suffix = ' (Calibrated)' if calibrated else ' (Raw)'
fig.update_layout(
title=f'Magnitude Plot - Sweep #{sweep.sweep_number}{title_suffix}',
xaxis_title='Point Index',
yaxis_title='Magnitude',
width=self.width,
height=self.height,
template='plotly_white',
showlegend=False
)
return fig
def _save_matplotlib_image(self, sweep: SweepData, magnitude_data: List[Tuple[float, float]], calibrated: bool = False) -> Path | None:
"""Save plot as image file using matplotlib."""
try:
# Add calibration indicator to filename
cal_suffix = "_cal" if calibrated else ""
filename = f"magnitude_sweep_{sweep.sweep_number:06d}{cal_suffix}.{self.image_format}"
file_path = self.output_dir / filename
# Extract data for plotting
indices = [point[0] for point in magnitude_data]
magnitudes = [point[1] for point in magnitude_data]
# Create matplotlib figure
fig, ax = plt.subplots(figsize=(self.width/100, self.height/100), dpi=100)
# Choose color and label based on calibration status
line_color = 'green' if calibrated else 'blue'
label = 'Magnitude (Calibrated)' if calibrated else 'Magnitude (Raw)'
ax.plot(indices, magnitudes, color=line_color, linewidth=2, label=label)
# Add calibration indicator to title
title_suffix = ' (Calibrated)' if calibrated else ' (Raw)'
ax.set_title(f'Magnitude Plot - Sweep #{sweep.sweep_number}{title_suffix}')
ax.set_xlabel('Point Index')
ax.set_ylabel('Magnitude')
ax.legend()
ax.grid(True, alpha=0.3)
# Add info text
info_text = f'Timestamp: {sweep.timestamp:.3f}s\nTotal Points: {sweep.total_points}'
ax.text(0.02, 0.98, info_text, transform=ax.transAxes,
verticalalignment='top', bbox=dict(boxstyle='round', facecolor='white', alpha=0.8))
# Save figure
plt.savefig(str(file_path), bbox_inches='tight', dpi=100)
plt.close(fig)
return file_path
except Exception as e:
print(f"Failed to save matplotlib image: {e}")
return None
def _calculate_magnitude_stats(self, magnitude_data: List[Tuple[float, float]]) -> Dict[str, float]:
"""Calculate basic statistics for magnitude data."""
if not magnitude_data:
return {}
magnitudes = [point[1] for point in magnitude_data]
magnitudes_array = np.array(magnitudes)
return {
"min": float(np.min(magnitudes_array)),
"max": float(np.max(magnitudes_array)),
"mean": float(np.mean(magnitudes_array)),
"std": float(np.std(magnitudes_array)),
"median": float(np.median(magnitudes_array)),
}

View File

@ -1,295 +0,0 @@
from __future__ import annotations
import json
import logging
import threading
from pathlib import Path
from typing import Any, Dict, List
from vna_system.core.processing.base_processor import ProcessingResult
logger = logging.getLogger(__name__)
class ResultsStorage:
"""Sweep-based file storage for processing results."""
def __init__(
self,
storage_dir: str = "processing_results",
max_sweeps: int = 10000,
sweeps_per_subdir: int = 1000
) -> None:
self.storage_dir = Path(storage_dir)
self.max_sweeps = max_sweeps
self.sweeps_per_subdir = sweeps_per_subdir
# Thread safety
self._lock = threading.RLock()
# Create storage directory
self.storage_dir.mkdir(parents=True, exist_ok=True)
# In-memory cache for quick lookup: {sweep_number: {processor_name: file_path, ...}}
self._cache: Dict[int, Dict[str, str]] = {}
self._build_cache()
def _build_cache(self) -> None:
"""Build in-memory cache by scanning storage directory."""
logger.info("Building results cache...")
self._cache = {}
# Scan all sweep subdirectories
for subdir in self.storage_dir.iterdir():
if subdir.is_dir() and subdir.name.startswith("sweeps_"):
# Scan sweep directories within subdirectory
for sweep_dir in subdir.iterdir():
if sweep_dir.is_dir() and sweep_dir.name.startswith("sweep_"):
try:
sweep_number = int(sweep_dir.name.split("_")[1])
self._cache[sweep_number] = {}
# Find all processor files in this sweep
for result_file in sweep_dir.glob("*.json"):
if result_file.name != "metadata.json":
processor_name = result_file.stem
self._cache[sweep_number][processor_name] = str(result_file)
except (ValueError, IndexError):
logger.warning(f"Invalid sweep directory name: {sweep_dir.name}")
logger.info(f"Built cache with {len(self._cache)} sweeps")
def _get_sweep_dir(self, sweep_number: int) -> Path:
"""Get directory path for specific sweep."""
# Group sweeps in subdirectories to avoid too many directories
subdir_index = sweep_number // self.sweeps_per_subdir
subdir_name = f"sweeps_{subdir_index:03d}_{(subdir_index + 1) * self.sweeps_per_subdir - 1:06d}"
sweep_name = f"sweep_{sweep_number:06d}"
sweep_dir = self.storage_dir / subdir_name / sweep_name
sweep_dir.mkdir(parents=True, exist_ok=True)
return sweep_dir
def _get_result_file_path(self, sweep_number: int, processor_name: str) -> Path:
"""Get file path for specific sweep and processor result."""
sweep_dir = self._get_sweep_dir(sweep_number)
return sweep_dir / f"{processor_name}.json"
def _get_metadata_file_path(self, sweep_number: int) -> Path:
"""Get metadata file path for specific sweep."""
sweep_dir = self._get_sweep_dir(sweep_number)
return sweep_dir / "metadata.json"
def store_result(self, result: ProcessingResult, overwrite: bool = False) -> None:
"""Store a processing result."""
with self._lock:
sweep_number = result.sweep_number
processor_name = result.processor_name
# Get file paths
result_file = self._get_result_file_path(sweep_number, processor_name)
metadata_file = self._get_metadata_file_path(sweep_number)
# Skip if result file already exists (don't overwrite unless forced)
if result_file.exists() and not overwrite: # SHOULD NOT HAPPEND
logger.debug(f"Result file already exists, skipping: {result_file}")
# Still update cache for existing file
if sweep_number not in self._cache:
self._cache[sweep_number] = {}
self._cache[sweep_number][processor_name] = str(result_file)
return
try:
# Store processor result
with open(result_file, 'w') as f:
json.dump(result.to_dict(), f, indent=2)
# Update/create metadata
metadata = {}
if metadata_file.exists():
try:
with open(metadata_file, 'r') as f:
metadata = json.load(f)
except Exception as e:
logger.warning(f"Failed to read metadata: {e}")
# Update metadata
metadata.update({
"sweep_number": sweep_number,
"timestamp": result.data.get("timestamp", 0),
"total_points": result.data.get("total_points", 0),
"processors": metadata.get("processors", [])
})
# Add processor to list if not already there
if processor_name not in metadata["processors"]:
metadata["processors"].append(processor_name)
# Save metadata
with open(metadata_file, 'w') as f:
json.dump(metadata, f, indent=2)
# Update cache
if sweep_number not in self._cache:
self._cache[sweep_number] = {}
self._cache[sweep_number][processor_name] = str(result_file)
# Cleanup old sweeps if needed
self._cleanup_old_sweeps()
logger.debug(f"Stored result for sweep {sweep_number}, processor {processor_name}")
except Exception as e:
logger.error(f"Failed to store result: {e}")
def _cleanup_old_sweeps(self) -> None:
"""Remove old sweeps if we exceed the limit."""
if len(self._cache) > self.max_sweeps:
# Sort by sweep number and remove oldest
sorted_sweeps = sorted(self._cache.keys())
sweeps_to_remove = sorted_sweeps[:len(sorted_sweeps) - self.max_sweeps]
for sweep_number in sweeps_to_remove:
try:
sweep_dir = self._get_sweep_dir(sweep_number)
# Remove all files in sweep directory
if sweep_dir.exists():
for file in sweep_dir.iterdir():
file.unlink()
sweep_dir.rmdir()
logger.debug(f"Removed sweep directory: {sweep_dir}")
# Remove from cache
del self._cache[sweep_number]
except Exception as e:
logger.error(f"Failed to remove sweep {sweep_number}: {e}")
def get_latest_results(self, processor_name: str | None = None, limit: int = 10) -> List[ProcessingResult]:
"""Get latest processing results."""
with self._lock:
results = []
# Get latest sweep numbers
sorted_sweeps = sorted(self._cache.keys(), reverse=True)
for sweep_number in sorted_sweeps[:limit * 2]: # Get more sweeps to find enough results
if len(results) >= limit:
break
sweep_results = self.get_result_by_sweep(sweep_number, processor_name)
results.extend(sweep_results)
return results[:limit]
def get_result_by_sweep(self, sweep_number: int, processor_name: str | None = None) -> List[ProcessingResult]:
"""Get processing results for specific sweep number."""
with self._lock:
results = []
if sweep_number not in self._cache:
return results
sweep_processors = self._cache[sweep_number]
# Filter by processor name if specified
if processor_name:
if processor_name in sweep_processors:
processors_to_load = [processor_name]
else:
processors_to_load = []
else:
processors_to_load = list(sweep_processors.keys())
# Load results for each processor
for proc_name in processors_to_load:
file_path = Path(sweep_processors[proc_name])
try:
if file_path.exists():
with open(file_path, 'r') as f:
result_data = json.load(f)
result = ProcessingResult(
processor_name=result_data["processor_name"],
sweep_number=result_data["sweep_number"],
data=result_data["data"],
plotly_figure=result_data.get("plotly_figure"),
file_path=result_data.get("file_path")
)
results.append(result)
except Exception as e:
logger.error(f"Failed to load result from {file_path}: {e}")
return results
def get_sweep_metadata(self, sweep_number: int) -> Dict[str, Any] | None:
"""Get metadata for specific sweep."""
with self._lock:
if sweep_number not in self._cache:
return None
metadata_file = self._get_metadata_file_path(sweep_number)
try:
if metadata_file.exists():
with open(metadata_file, 'r') as f:
return json.load(f)
except Exception as e:
logger.error(f"Failed to read metadata for sweep {sweep_number}: {e}")
return None
def get_available_sweeps(self, limit: int | None = None) -> List[int]:
"""Get list of available sweep numbers."""
with self._lock:
sorted_sweeps = sorted(self._cache.keys(), reverse=True)
if limit:
return sorted_sweeps[:limit]
return sorted_sweeps
def get_processors_for_sweep(self, sweep_number: int) -> List[str]:
"""Get list of processors that have results for specific sweep."""
with self._lock:
if sweep_number in self._cache:
return list(self._cache[sweep_number].keys())
return []
def get_storage_stats(self) -> Dict[str, Any]:
"""Get storage statistics."""
with self._lock:
if not self._cache:
return {
"storage_dir": str(self.storage_dir),
"total_sweeps": 0,
"processors": {},
"oldest_sweep": None,
"newest_sweep": None
}
# Calculate processor statistics
processor_counts = {}
for sweep_processors in self._cache.values():
for processor_name in sweep_processors.keys():
processor_counts[processor_name] = processor_counts.get(processor_name, 0) + 1
sorted_sweeps = sorted(self._cache.keys())
return {
"storage_dir": str(self.storage_dir),
"total_sweeps": len(self._cache),
"processors": processor_counts,
"oldest_sweep": sorted_sweeps[0] if sorted_sweeps else None,
"newest_sweep": sorted_sweeps[-1] if sorted_sweeps else None,
"storage_structure": "sweep-based"
}
def get_max_sweep_number(self) -> int:
"""Get the maximum sweep number currently stored."""
with self._lock:
if not self._cache:
return 0
return max(self._cache.keys())

View File

@ -1,231 +0,0 @@
from __future__ import annotations
import json
import logging
import queue
import threading
import time
from typing import Any, Dict, List
from vna_system.core.acquisition.sweep_buffer import SweepBuffer, SweepData
from vna_system.core.processing.base_processor import BaseSweepProcessor, ProcessingResult
from vna_system.core.processing.processors.magnitude_plot import MagnitudePlotProcessor
from vna_system.core.processing.results_storage import ResultsStorage
logger = logging.getLogger(__name__)
class SweepProcessingManager:
"""Manager for sweep data processing with configurable processors and queue-based results."""
def __init__(self, config_path: str = "vna_system/core/processing/config.json") -> None:
self.processors: List[BaseSweepProcessor] = []
self.config: Dict[str, Any] = {}
self.sweep_buffer: SweepBuffer | None = None
# Processing control
self._running = False
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._last_processed_sweep = 0
# Results queue for websocket consumption
self.results_queue: queue.Queue[ProcessingResult] = queue.Queue(maxsize=10)
# Load configuration first
self.load_config(config_path)
# File-based results storage (after config is loaded)
storage_config = self.config.get("storage", {})
self.results_storage = ResultsStorage(
storage_dir=storage_config.get("dir", "processing_results"),
max_sweeps=storage_config.get("max_sweeps", 10000),
sweeps_per_subdir=storage_config.get("sweeps_per_subdir", 1000)
)
def load_config(self, config_path: str) -> None:
"""Load processing configuration from JSON file."""
try:
with open(config_path, 'r') as f:
self.config = json.load(f)
self._initialize_processors()
logger.info(f"Loaded processing config from {config_path}")
except Exception as e:
logger.error(f"Failed to load config from {config_path}: {e}")
def _initialize_processors(self) -> None:
"""Initialize processors based on configuration."""
self.processors.clear()
processor_configs = self.config.get("processors", {})
# Initialize magnitude plot processor
if "magnitude_plot" in processor_configs:
magnitude_config = processor_configs["magnitude_plot"]
self.processors.append(MagnitudePlotProcessor(magnitude_config))
logger.info(f"Initialized {len(self.processors)} processors")
def set_sweep_buffer(self, sweep_buffer: SweepBuffer) -> None:
"""Set the sweep buffer to process data from."""
self.sweep_buffer = sweep_buffer
# Initialize sweep counter to continue from existing data
try:
max_sweep = self.results_storage.get_max_sweep_number()
if max_sweep > 0:
self.sweep_buffer.set_sweep_counter(max_sweep)
self._last_processed_sweep = max_sweep # Sync our tracking
logger.info(f"Set sweep buffer to continue from sweep #{max_sweep + 1}")
else:
logger.info("Sweep buffer starts from #1")
except Exception as e:
logger.warning(f"Failed to set sweep counter: {e}")
def start(self) -> None:
"""Start the processing thread."""
if self._running:
logger.debug("Processing already running; start() call ignored.")
return
if not self.sweep_buffer:
logger.error("Cannot start processing without sweep buffer")
return
self._running = True
self._stop_event.clear()
self._thread = threading.Thread(target=self._processing_loop, daemon=True)
self._thread.start()
logger.info("Sweep processing started")
def stop(self) -> None:
"""Stop the processing thread."""
if not self._running:
logger.debug("Processing not running; stop() call ignored.")
return
self._running = False
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5.0)
logger.info("Processing thread joined")
@property
def is_running(self) -> bool:
"""Check if processing is currently running."""
return self._running and (self._thread is not None and self._thread.is_alive())
def _processing_loop(self) -> None:
"""Main processing loop executed by the background thread."""
while self._running and not self._stop_event.is_set():
try:
if not self.sweep_buffer:
time.sleep(0.1)
continue
# Get latest sweep
latest_sweep = self.sweep_buffer.get_latest_sweep()
if not latest_sweep:
time.sleep(0.1)
continue
# Process new sweeps
if latest_sweep.sweep_number > self._last_processed_sweep:
self._process_sweep(latest_sweep)
self._last_processed_sweep = latest_sweep.sweep_number
time.sleep(0.05) # Small delay to avoid busy waiting
except Exception as e:
logger.error(f"Processing loop error: {e}")
time.sleep(1.0)
def _process_sweep(self, sweep: SweepData) -> None:
"""Process a single sweep with all configured processors."""
logger.debug(f"Processing sweep #{sweep.sweep_number}")
for processor in self.processors:
try:
result = processor.process(sweep)
if result:
self._store_result(result)
self._queue_result(result)
logger.debug(f"Processed sweep #{sweep.sweep_number} with {processor.name}")
except Exception as e:
logger.error(f"Error in processor {processor.name}: {e}")
def _store_result(self, result: ProcessingResult) -> None:
"""Store processing result in file storage."""
self.results_storage.store_result(result)
def _queue_result(self, result: ProcessingResult) -> None:
"""Queue processing result for websocket consumption."""
try:
self.results_queue.put_nowait(result)
except queue.Full:
logger.warning("Results queue is full, dropping oldest result")
try:
self.results_queue.get_nowait() # Remove oldest
self.results_queue.put_nowait(result) # Add new
except queue.Empty:
pass
def get_next_result(self, timeout: float | None = None) -> ProcessingResult | None:
"""Get next processing result from queue. Used by websocket handler."""
try:
return self.results_queue.get(timeout=timeout)
except queue.Empty:
return None
def get_pending_results_count(self) -> int:
"""Get number of pending results in queue."""
return self.results_queue.qsize()
def drain_results_queue(self) -> List[ProcessingResult]:
"""Drain all pending results from queue. Used for batch processing."""
results = []
while True:
try:
result = self.results_queue.get_nowait()
results.append(result)
except queue.Empty:
break
return results
def get_latest_results(self, processor_name: str | None = None, limit: int = 10) -> List[ProcessingResult]:
"""Get latest processing results from storage, optionally filtered by processor name."""
return self.results_storage.get_latest_results(processor_name, limit)
def get_result_by_sweep(self, sweep_number: int, processor_name: str | None = None) -> List[ProcessingResult]:
"""Get processing results for a specific sweep number from storage."""
return self.results_storage.get_result_by_sweep(sweep_number, processor_name)
def add_processor(self, processor: BaseSweepProcessor) -> None:
"""Add a processor manually."""
self.processors.append(processor)
logger.info(f"Added processor: {processor.name}")
def remove_processor(self, processor_name: str) -> bool:
"""Remove a processor by name."""
for i, processor in enumerate(self.processors):
if processor.name == processor_name:
del self.processors[i]
logger.info(f"Removed processor: {processor_name}")
return True
return False
def get_processing_stats(self) -> Dict[str, Any]:
"""Get processing statistics."""
storage_stats = self.results_storage.get_storage_stats()
return {
"is_running": self.is_running,
"processors_count": len(self.processors),
"processor_names": [p.name for p in self.processors],
"last_processed_sweep": self._last_processed_sweep,
"results_queue_size": self.results_queue.qsize(),
"storage_stats": storage_stats,
}

View File

@ -1,165 +0,0 @@
#!/usr/bin/env python3
"""
WebSocket handler for real-time sweep data processing results using FastAPI.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, Dict, List
from fastapi import WebSocket, WebSocketDisconnect
from vna_system.core.processing.sweep_processor import SweepProcessingManager
logger = logging.getLogger(__name__)
class WebSocketManager:
"""WebSocket manager for streaming processing results to clients."""
def __init__(self, processing_manager: SweepProcessingManager) -> None:
self.processing_manager = processing_manager
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket) -> None:
"""Accept a new WebSocket connection."""
await websocket.accept()
self.active_connections.append(websocket)
logger.info(f"Client connected. Total clients: {len(self.active_connections)}")
def disconnect(self, websocket: WebSocket) -> None:
"""Remove WebSocket connection."""
if websocket in self.active_connections:
self.active_connections.remove(websocket)
logger.info(f"Client disconnected. Total clients: {len(self.active_connections)}")
async def send_personal_message(self, message: Dict[str, Any], websocket: WebSocket) -> None:
"""Send message to specific websocket."""
try:
await websocket.send_text(json.dumps(message))
except Exception as e:
logger.error(f"Error sending message to client: {e}")
async def broadcast(self, message: Dict[str, Any]) -> None:
"""Send message to all connected clients."""
if not self.active_connections:
return
disconnected = []
for connection in self.active_connections:
try:
await connection.send_text(json.dumps(message))
except Exception as e:
logger.error(f"Error broadcasting to client: {e}")
disconnected.append(connection)
# Remove failed connections
for connection in disconnected:
self.disconnect(connection)
async def handle_websocket(self, websocket: WebSocket) -> None:
"""Handle WebSocket connection."""
await self.connect(websocket)
try:
# Send initial status
await self.send_personal_message({
"type": "status",
"data": {
"connected": True,
"processing_stats": self.processing_manager.get_processing_stats()
}
}, websocket)
# Start processing results stream
stream_task = asyncio.create_task(self._stream_results(websocket))
try:
# Handle incoming messages
while True:
data = await websocket.receive_text()
try:
message = json.loads(data)
await self._handle_message(websocket, message)
except json.JSONDecodeError:
await self.send_personal_message({
"type": "error",
"message": "Invalid JSON format"
}, websocket)
except WebSocketDisconnect:
logger.info("WebSocket disconnected")
except Exception as e:
logger.error(f"WebSocket message handling error: {e}")
except WebSocketDisconnect:
logger.info("WebSocket disconnected")
except Exception as e:
logger.error(f"WebSocket error: {e}")
finally:
stream_task.cancel()
self.disconnect(websocket)
async def _handle_message(self, websocket: WebSocket, message: Dict[str, Any]) -> None:
"""Handle incoming client message."""
message_type = message.get("type")
if message_type == "get_stats":
await self.send_personal_message({
"type": "stats",
"data": self.processing_manager.get_processing_stats()
}, websocket)
elif message_type == "get_history":
processor_name = message.get("processor_name")
limit = message.get("limit", 10)
results = self.processing_manager.get_latest_results(processor_name)[-limit:]
await self.send_personal_message({
"type": "history",
"data": [result.to_dict() for result in results]
}, websocket)
elif message_type == "get_sweep":
sweep_number = message.get("sweep_number")
processor_name = message.get("processor_name")
if sweep_number is not None:
results = self.processing_manager.get_result_by_sweep(sweep_number, processor_name)
await self.send_personal_message({
"type": "sweep_data",
"data": [result.to_dict() for result in results]
}, websocket)
else:
await self.send_personal_message({
"type": "error",
"message": "sweep_number is required"
}, websocket)
else:
await self.send_personal_message({
"type": "error",
"message": f"Unknown message type: {message_type}"
}, websocket)
async def _stream_results(self, websocket: WebSocket) -> None:
"""Stream processing results to websocket."""
while websocket in self.active_connections:
try:
result = self.processing_manager.get_next_result(timeout=0.1)
if result:
await self.send_personal_message({
"type": "processing_result",
"data": result.to_dict()
}, websocket)
await asyncio.sleep(0.05)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error streaming results: {e}")
await asyncio.sleep(1.0)

View File

View File

@ -0,0 +1,190 @@
from abc import ABC, abstractmethod
from typing import Dict, Any, List, Optional
from dataclasses import dataclass
from pathlib import Path
import json
import threading
from datetime import datetime
from vna_system.core.settings.preset_manager import ConfigPreset
@dataclass
class UIParameter:
name: str
label: str
type: str # 'slider', 'toggle', 'select', 'input'
value: Any
options: Optional[Dict[str, Any]] = None # min/max for slider, choices for select, etc.
@dataclass
class ProcessedResult:
processor_id: str
timestamp: float
data: Dict[str, Any]
plotly_config: Dict[str, Any]
ui_parameters: List[UIParameter]
metadata: Dict[str, Any]
class BaseProcessor(ABC):
def __init__(self, processor_id: str, config_dir: Path):
self.processor_id = processor_id
self.config_dir = config_dir
self.config_file = config_dir / f"{processor_id}_config.json"
self._lock = threading.RLock()
self._sweep_history: List[Any] = []
self._max_history = 1
self._config = {}
self._load_config()
@property
def max_history(self) -> int:
return self._max_history
@max_history.setter
def max_history(self, value: int):
with self._lock:
self._max_history = max(1, value)
self._trim_history()
def _trim_history(self):
if len(self._sweep_history) > self._max_history:
self._sweep_history = self._sweep_history[-self._max_history:]
def _load_config(self):
if self.config_file.exists():
try:
with open(self.config_file, 'r') as f:
self._config = json.load(f)
self._validate_config()
except (json.JSONDecodeError, FileNotFoundError):
self._config = self._get_default_config()
self.save_config()
else:
self._config = self._get_default_config()
self.save_config()
def save_config(self):
self.config_dir.mkdir(parents=True, exist_ok=True)
with open(self.config_file, 'w') as f:
json.dump(self._config, f, indent=2)
def update_config(self, updates: Dict[str, Any]):
with self._lock:
old_config = self._config.copy()
# Convert types based on existing config values
converted_updates = self._convert_config_types(updates)
self._config.update(converted_updates)
try:
self._validate_config()
self.save_config()
except Exception as e:
self._config = old_config
raise ValueError(f"Invalid configuration: {e}")
def _convert_config_types(self, updates: Dict[str, Any]) -> Dict[str, Any]:
"""Convert string values to appropriate types based on existing config"""
converted = {}
for key, value in updates.items():
# If the key is not in the current config, keep the value as-is
if key not in self._config:
converted[key] = value
continue
existing_value = self._config[key]
# Convert booleans from string
if isinstance(existing_value, bool) and isinstance(value, str):
converted[key] = value.lower() in ('true', '1', 'on', 'yes')
continue
# Convert numbers from string
if isinstance(existing_value, (int, float)) and isinstance(value, str):
try:
if isinstance(existing_value, int):
# Handle cases like "50.0" → 50
converted[key] = int(float(value))
else:
converted[key] = float(value)
except ValueError:
# Keep the original string if conversion fails
converted[key] = value
continue
# For all other cases, keep the value as-is
converted[key] = value
return converted
def get_config(self) -> Dict[str, Any]:
return self._config.copy()
def add_sweep_data(self, sweep_data: Any, calibrated_data: Any, vna_config: ConfigPreset | None):
with self._lock:
self._sweep_history.append({
'sweep_data': sweep_data,
'calibrated_data': calibrated_data,
'vna_config': vna_config.__dict__ if vna_config is not None else {},
'timestamp': datetime.now().timestamp()
})
self._trim_history()
return self.recalculate()
def recalculate(self) -> Optional[ProcessedResult]:
with self._lock:
if not self._sweep_history:
return None
latest = self._sweep_history[-1]
return self._process_data(
latest['sweep_data'],
latest['calibrated_data'],
latest['vna_config']
)
def _process_data(self, sweep_data: Any, calibrated_data: Any, vna_config: Dict[str, Any]) -> ProcessedResult:
processed_data = self.process_sweep(sweep_data, calibrated_data, vna_config)
plotly_config = self.generate_plotly_config(processed_data, vna_config)
ui_parameters = self.get_ui_parameters()
return ProcessedResult(
processor_id=self.processor_id,
timestamp=datetime.now().timestamp(),
data=processed_data,
plotly_config=plotly_config,
ui_parameters=ui_parameters,
metadata=self._get_metadata()
)
@abstractmethod
def process_sweep(self, sweep_data: Any, calibrated_data: Any, vna_config: Dict[str, Any]) -> Dict[str, Any]:
pass
@abstractmethod
def generate_plotly_config(self, processed_data: Dict[str, Any], vna_config: Dict[str, Any]) -> Dict[str, Any]:
pass
@abstractmethod
def get_ui_parameters(self) -> List[UIParameter]:
pass
@abstractmethod
def _get_default_config(self) -> Dict[str, Any]:
pass
@abstractmethod
def _validate_config(self):
pass
def _get_metadata(self) -> Dict[str, Any]:
return {
'processor_id': self.processor_id,
'config': self._config,
'history_count': len(self._sweep_history),
'max_history': self._max_history
}

View File

@ -6,13 +6,11 @@ Supports both S11 and S21 measurement modes with appropriate correction algorith
"""
import numpy as np
from pathlib import Path
from typing import Optional
from typing import List, Tuple
from vna_system.core.acquisition.sweep_buffer import SweepData
from vna_system.core.settings.preset_manager import VNAMode
from vna_system.core.settings.calibration_manager import CalibrationSet
from vna_system.core.settings.calibration_manager import CalibrationStandard
from ..acquisition.sweep_buffer import SweepData
from ..settings.preset_manager import VNAMode
from ..settings.calibration_manager import CalibrationSet, CalibrationStandard
class CalibrationProcessor:
@ -26,16 +24,16 @@ class CalibrationProcessor:
def __init__(self):
pass
def apply_calibration(self, sweep_data: SweepData, calibration_set: CalibrationSet) -> np.ndarray:
def apply_calibration(self, sweep_data: SweepData, calibration_set: CalibrationSet) -> List[Tuple[float, float]]:
"""
Apply calibration to sweep data and return corrected complex array.
Apply calibration to sweep data and return corrected complex data as list of (real, imag) tuples.
Args:
sweep_data: Raw sweep data from VNA
calibration_set: Calibration standards data
Returns:
Complex array with calibration applied
List of (real, imag) tuples with calibration applied
Raises:
ValueError: If calibration is incomplete or mode not supported
@ -48,12 +46,15 @@ class CalibrationProcessor:
# Apply calibration based on measurement mode
if calibration_set.preset.mode == VNAMode.S21:
return self._apply_s21_calibration(raw_signal, calibration_set)
calibrated_array = self._apply_s21_calibration(raw_signal, calibration_set)
elif calibration_set.preset.mode == VNAMode.S11:
return self._apply_s11_calibration(raw_signal, calibration_set)
calibrated_array = self._apply_s11_calibration(raw_signal, calibration_set)
else:
raise ValueError(f"Unsupported measurement mode: {calibration_set.preset.mode}")
# Convert back to list of (real, imag) tuples
return [(complex_val.real, complex_val.imag) for complex_val in calibrated_array]
def _sweep_to_complex_array(self, sweep_data: SweepData) -> np.ndarray:
"""Convert SweepData to complex numpy array."""
complex_data = []
@ -95,7 +96,6 @@ class CalibrationProcessor:
Final correction: S11 = (Raw - Ed) / (Er + Es * (Raw - Ed))
"""
from vna_system.core.settings.calibration_manager import CalibrationStandard
# Get calibration standards
open_sweep = calibration_set.standards[CalibrationStandard.OPEN]
@ -131,4 +131,4 @@ class CalibrationProcessor:
calibrated_signal = corrected_numerator / corrected_denominator
return calibrated_signal
return calibrated_signal

View File

@ -0,0 +1,9 @@
{
"y_min": -110,
"y_max": 10,
"smoothing_enabled": false,
"smoothing_window": 17,
"marker_enabled": true,
"marker_frequency": 1,
"grid_enabled": false
}

View File

@ -0,0 +1,13 @@
{
"y_min": -210,
"y_max": 360,
"unwrap_phase": false,
"phase_offset": -60,
"smoothing_enabled": true,
"smoothing_window": 5,
"marker_enabled": false,
"marker_frequency": 2000000000,
"reference_line_enabled": false,
"reference_phase": 0,
"grid_enabled": true
}

View File

@ -0,0 +1,9 @@
{
"impedance_mode": true,
"reference_impedance": 100,
"marker_enabled": true,
"marker_frequency": 1000000000,
"grid_circles": true,
"grid_radials": true,
"trace_color_mode": "solid"
}

View File

@ -0,0 +1,5 @@
from .magnitude_processor import MagnitudeProcessor
from .phase_processor import PhaseProcessor
from .smith_chart_processor import SmithChartProcessor
__all__ = ['MagnitudeProcessor', 'PhaseProcessor', 'SmithChartProcessor']

View File

@ -0,0 +1,184 @@
import numpy as np
from typing import Dict, Any, List
from pathlib import Path
from ..base_processor import BaseProcessor, UIParameter
class MagnitudeProcessor(BaseProcessor):
def __init__(self, config_dir: Path):
super().__init__("magnitude", config_dir)
def process_sweep(self, sweep_data: Any, calibrated_data: Any, vna_config: Dict[str, Any]) -> Dict[str, Any]:
if not calibrated_data or not hasattr(calibrated_data, 'points'):
return {'error': 'No calibrated data available'}
frequencies = []
magnitudes_db = []
for i, (real, imag) in enumerate(calibrated_data.points):
complex_val = complex(real, imag)
magnitude_db = 20 * np.log10(abs(complex_val)) if abs(complex_val) > 0 else -120
# Calculate frequency based on VNA config
start_freq = vna_config.get('start_frequency', 100e6)
stop_freq = vna_config.get('stop_frequency', 8.8e9)
total_points = len(calibrated_data.points)
frequency = start_freq + (stop_freq - start_freq) * i / (total_points - 1)
frequencies.append(frequency)
magnitudes_db.append(magnitude_db)
# Apply smoothing if enabled
if self._config.get('smoothing_enabled', False):
window_size = self._config.get('smoothing_window', 5)
magnitudes_db = self._apply_moving_average(magnitudes_db, window_size)
return {
'frequencies': frequencies,
'magnitudes_db': magnitudes_db,
'y_min': self._config.get('y_min', -80),
'y_max': self._config.get('y_max', 10),
'marker_enabled': self._config.get('marker_enabled', True),
'marker_frequency': self._config.get('marker_frequency', frequencies[len(frequencies)//2] if frequencies else 1e9),
'grid_enabled': self._config.get('grid_enabled', True)
}
def generate_plotly_config(self, processed_data: Dict[str, Any], vna_config: Dict[str, Any]) -> Dict[str, Any]:
if 'error' in processed_data:
return {'error': processed_data['error']}
frequencies = processed_data['frequencies']
magnitudes_db = processed_data['magnitudes_db']
# Find marker point
marker_freq = processed_data['marker_frequency']
marker_idx = min(range(len(frequencies)), key=lambda i: abs(frequencies[i] - marker_freq))
marker_mag = magnitudes_db[marker_idx]
traces = [{
'x': [f / 1e9 for f in frequencies], # Convert to GHz
'y': magnitudes_db,
'type': 'scatter',
'mode': 'lines',
'name': 'S11 Magnitude',
'line': {'color': 'blue', 'width': 2}
}]
# Add marker if enabled
if processed_data['marker_enabled']:
traces.append({
'x': [frequencies[marker_idx] / 1e9],
'y': [marker_mag],
'type': 'scatter',
'mode': 'markers',
'name': f'Marker: {frequencies[marker_idx]/1e9:.3f} GHz, {marker_mag:.2f} dB',
'marker': {'color': 'red', 'size': 8, 'symbol': 'circle'}
})
return {
'data': traces,
'layout': {
'title': 'S11 Magnitude Response',
'xaxis': {
'title': 'Frequency (GHz)',
'showgrid': processed_data['grid_enabled']
},
'yaxis': {
'title': 'Magnitude (dB)',
'range': [processed_data['y_min'], processed_data['y_max']],
'showgrid': processed_data['grid_enabled']
},
'hovermode': 'x unified',
'showlegend': True
}
}
def get_ui_parameters(self) -> List[UIParameter]:
return [
UIParameter(
name='y_min',
label='Y Axis Min (dB)',
type='slider',
value=self._config.get('y_min', -80),
options={'min': -120, 'max': 0, 'step': 5}
),
UIParameter(
name='y_max',
label='Y Axis Max (dB)',
type='slider',
value=self._config.get('y_max', 10),
options={'min': -20, 'max': 20, 'step': 5}
),
UIParameter(
name='smoothing_enabled',
label='Enable Smoothing',
type='toggle',
value=self._config.get('smoothing_enabled', False)
),
UIParameter(
name='smoothing_window',
label='Smoothing Window Size',
type='slider',
value=self._config.get('smoothing_window', 5),
options={'min': 3, 'max': 21, 'step': 2}
),
UIParameter(
name='marker_enabled',
label='Show Marker',
type='toggle',
value=self._config.get('marker_enabled', True)
),
UIParameter(
name='marker_frequency',
label='Marker Frequency (Hz)',
type='input',
value=self._config.get('marker_frequency', 1e9),
options={'type': 'number', 'min': 100e6, 'max': 8.8e9}
),
UIParameter(
name='grid_enabled',
label='Show Grid',
type='toggle',
value=self._config.get('grid_enabled', True)
)
]
def _get_default_config(self) -> Dict[str, Any]:
return {
'y_min': -80,
'y_max': 10,
'smoothing_enabled': False,
'smoothing_window': 5,
'marker_enabled': True,
'marker_frequency': 1e9,
'grid_enabled': True
}
def _validate_config(self):
required_keys = ['y_min', 'y_max', 'smoothing_enabled', 'smoothing_window',
'marker_enabled', 'marker_frequency', 'grid_enabled']
for key in required_keys:
if key not in self._config:
raise ValueError(f"Missing required config key: {key}")
if self._config['y_min'] >= self._config['y_max']:
raise ValueError("y_min must be less than y_max")
if self._config['smoothing_window'] < 3 or self._config['smoothing_window'] % 2 == 0:
raise ValueError("smoothing_window must be odd and >= 3")
def _apply_moving_average(self, data: List[float], window_size: int) -> List[float]:
if window_size >= len(data):
return data
smoothed = []
half_window = window_size // 2
for i in range(len(data)):
start_idx = max(0, i - half_window)
end_idx = min(len(data), i + half_window + 1)
smoothed.append(sum(data[start_idx:end_idx]) / (end_idx - start_idx))
return smoothed

View File

@ -0,0 +1,242 @@
import numpy as np
from typing import Dict, Any, List
from pathlib import Path
from ..base_processor import BaseProcessor, UIParameter
class PhaseProcessor(BaseProcessor):
def __init__(self, config_dir: Path):
super().__init__("phase", config_dir)
def process_sweep(self, sweep_data: Any, calibrated_data: Any, vna_config: Dict[str, Any]) -> Dict[str, Any]:
if not calibrated_data or not hasattr(calibrated_data, 'points'):
return {'error': 'No calibrated data available'}
frequencies = []
phases_deg = []
for i, (real, imag) in enumerate(calibrated_data.points):
complex_val = complex(real, imag)
phase_rad = np.angle(complex_val)
phase_deg = np.degrees(phase_rad)
# Phase unwrapping if enabled
if self._config.get('unwrap_phase', True) and i > 0:
phase_diff = phase_deg - phases_deg[-1]
if phase_diff > 180:
phase_deg -= 360
elif phase_diff < -180:
phase_deg += 360
# Calculate frequency
start_freq = vna_config.get('start_frequency', 100e6)
stop_freq = vna_config.get('stop_frequency', 8.8e9)
total_points = len(calibrated_data.points)
frequency = start_freq + (stop_freq - start_freq) * i / (total_points - 1)
frequencies.append(frequency)
phases_deg.append(phase_deg)
# Apply offset if configured
phase_offset = self._config.get('phase_offset', 0)
if phase_offset != 0:
phases_deg = [phase + phase_offset for phase in phases_deg]
# Apply smoothing if enabled
if self._config.get('smoothing_enabled', False):
window_size = self._config.get('smoothing_window', 5)
phases_deg = self._apply_moving_average(phases_deg, window_size)
return {
'frequencies': frequencies,
'phases_deg': phases_deg,
'y_min': self._config.get('y_min', -180),
'y_max': self._config.get('y_max', 180),
'marker_enabled': self._config.get('marker_enabled', True),
'marker_frequency': self._config.get('marker_frequency', frequencies[len(frequencies)//2] if frequencies else 1e9),
'grid_enabled': self._config.get('grid_enabled', True),
'reference_line_enabled': self._config.get('reference_line_enabled', False),
'reference_phase': self._config.get('reference_phase', 0)
}
def generate_plotly_config(self, processed_data: Dict[str, Any], vna_config: Dict[str, Any]) -> Dict[str, Any]:
if 'error' in processed_data:
return {'error': processed_data['error']}
frequencies = processed_data['frequencies']
phases_deg = processed_data['phases_deg']
# Find marker point
marker_freq = processed_data['marker_frequency']
marker_idx = min(range(len(frequencies)), key=lambda i: abs(frequencies[i] - marker_freq))
marker_phase = phases_deg[marker_idx]
traces = [{
'x': [f / 1e9 for f in frequencies], # Convert to GHz
'y': phases_deg,
'type': 'scatter',
'mode': 'lines',
'name': 'S11 Phase',
'line': {'color': 'green', 'width': 2}
}]
# Add marker if enabled
if processed_data['marker_enabled']:
traces.append({
'x': [frequencies[marker_idx] / 1e9],
'y': [marker_phase],
'type': 'scatter',
'mode': 'markers',
'name': f'Marker: {frequencies[marker_idx]/1e9:.3f} GHz, {marker_phase:.1f}°',
'marker': {'color': 'red', 'size': 8, 'symbol': 'circle'}
})
# Add reference line if enabled
if processed_data['reference_line_enabled']:
traces.append({
'x': [frequencies[0] / 1e9, frequencies[-1] / 1e9],
'y': [processed_data['reference_phase'], processed_data['reference_phase']],
'type': 'scatter',
'mode': 'lines',
'name': f'Reference: {processed_data["reference_phase"]:.1f}°',
'line': {'color': 'gray', 'width': 1, 'dash': 'dash'}
})
return {
'data': traces,
'layout': {
'title': 'S11 Phase Response',
'xaxis': {
'title': 'Frequency (GHz)',
'showgrid': processed_data['grid_enabled']
},
'yaxis': {
'title': 'Phase (degrees)',
'range': [processed_data['y_min'], processed_data['y_max']],
'showgrid': processed_data['grid_enabled']
},
'hovermode': 'x unified',
'showlegend': True
}
}
def get_ui_parameters(self) -> List[UIParameter]:
return [
UIParameter(
name='y_min',
label='Y Axis Min (degrees)',
type='slider',
value=self._config.get('y_min', -180),
options={'min': -360, 'max': 0, 'step': 15}
),
UIParameter(
name='y_max',
label='Y Axis Max (degrees)',
type='slider',
value=self._config.get('y_max', 180),
options={'min': 0, 'max': 360, 'step': 15}
),
UIParameter(
name='unwrap_phase',
label='Unwrap Phase',
type='toggle',
value=self._config.get('unwrap_phase', True)
),
UIParameter(
name='phase_offset',
label='Phase Offset (degrees)',
type='slider',
value=self._config.get('phase_offset', 0),
options={'min': -180, 'max': 180, 'step': 5}
),
UIParameter(
name='smoothing_enabled',
label='Enable Smoothing',
type='toggle',
value=self._config.get('smoothing_enabled', False)
),
UIParameter(
name='smoothing_window',
label='Smoothing Window Size',
type='slider',
value=self._config.get('smoothing_window', 5),
options={'min': 3, 'max': 21, 'step': 2}
),
UIParameter(
name='marker_enabled',
label='Show Marker',
type='toggle',
value=self._config.get('marker_enabled', True)
),
UIParameter(
name='marker_frequency',
label='Marker Frequency (Hz)',
type='input',
value=self._config.get('marker_frequency', 1e9),
options={'type': 'number', 'min': 100e6, 'max': 8.8e9}
),
UIParameter(
name='reference_line_enabled',
label='Show Reference Line',
type='toggle',
value=self._config.get('reference_line_enabled', False)
),
UIParameter(
name='reference_phase',
label='Reference Phase (degrees)',
type='slider',
value=self._config.get('reference_phase', 0),
options={'min': -180, 'max': 180, 'step': 15}
),
UIParameter(
name='grid_enabled',
label='Show Grid',
type='toggle',
value=self._config.get('grid_enabled', True)
)
]
def _get_default_config(self) -> Dict[str, Any]:
return {
'y_min': -180,
'y_max': 180,
'unwrap_phase': True,
'phase_offset': 0,
'smoothing_enabled': False,
'smoothing_window': 5,
'marker_enabled': True,
'marker_frequency': 1e9,
'reference_line_enabled': False,
'reference_phase': 0,
'grid_enabled': True
}
def _validate_config(self):
required_keys = ['y_min', 'y_max', 'unwrap_phase', 'phase_offset',
'smoothing_enabled', 'smoothing_window', 'marker_enabled',
'marker_frequency', 'reference_line_enabled', 'reference_phase', 'grid_enabled']
for key in required_keys:
if key not in self._config:
raise ValueError(f"Missing required config key: {key}")
if self._config['y_min'] >= self._config['y_max']:
raise ValueError("y_min must be less than y_max")
if self._config['smoothing_window'] < 3 or self._config['smoothing_window'] % 2 == 0:
raise ValueError("smoothing_window must be odd and >= 3")
def _apply_moving_average(self, data: List[float], window_size: int) -> List[float]:
if window_size >= len(data):
return data
smoothed = []
half_window = window_size // 2
for i in range(len(data)):
start_idx = max(0, i - half_window)
end_idx = min(len(data), i + half_window + 1)
smoothed.append(sum(data[start_idx:end_idx]) / (end_idx - start_idx))
return smoothed

View File

@ -0,0 +1,303 @@
import numpy as np
from typing import Dict, Any, List
from pathlib import Path
from ..base_processor import BaseProcessor, UIParameter
class SmithChartProcessor(BaseProcessor):
def __init__(self, config_dir: Path):
super().__init__("smith_chart", config_dir)
def process_sweep(self, sweep_data: Any, calibrated_data: Any, vna_config: Dict[str, Any]) -> Dict[str, Any]:
if not calibrated_data or not hasattr(calibrated_data, 'points'):
return {'error': 'No calibrated data available'}
frequencies = []
real_parts = []
imag_parts = []
reflection_coeffs = []
for i, (real, imag) in enumerate(calibrated_data.points):
complex_val = complex(real, imag)
# Calculate frequency
start_freq = vna_config.get('start_frequency', 100e6)
stop_freq = vna_config.get('stop_frequency', 8.8e9)
total_points = len(calibrated_data.points)
frequency = start_freq + (stop_freq - start_freq) * i / (total_points - 1)
frequencies.append(frequency)
real_parts.append(real)
imag_parts.append(imag)
reflection_coeffs.append(complex_val)
# Convert to impedance if requested
impedance_mode = self._config.get('impedance_mode', False)
z0 = self._config.get('reference_impedance', 50)
if impedance_mode:
impedances = [(z0 * (1 + gamma) / (1 - gamma)) for gamma in reflection_coeffs]
plot_real = [z.real for z in impedances]
plot_imag = [z.imag for z in impedances]
else:
plot_real = real_parts
plot_imag = imag_parts
return {
'frequencies': frequencies,
'real_parts': plot_real,
'imag_parts': plot_imag,
'impedance_mode': impedance_mode,
'reference_impedance': z0,
'marker_enabled': self._config.get('marker_enabled', True),
'marker_frequency': self._config.get('marker_frequency', frequencies[len(frequencies)//2] if frequencies else 1e9),
'grid_circles': self._config.get('grid_circles', True),
'grid_radials': self._config.get('grid_radials', True),
'trace_color_mode': self._config.get('trace_color_mode', 'frequency')
}
def generate_plotly_config(self, processed_data: Dict[str, Any], vna_config: Dict[str, Any]) -> Dict[str, Any]:
if 'error' in processed_data:
return {'error': processed_data['error']}
real_parts = processed_data['real_parts']
imag_parts = processed_data['imag_parts']
frequencies = processed_data['frequencies']
# Find marker point
marker_freq = processed_data['marker_frequency']
marker_idx = min(range(len(frequencies)), key=lambda i: abs(frequencies[i] - marker_freq))
# Create main trace
trace_config = {
'x': real_parts,
'y': imag_parts,
'type': 'scatter',
'mode': 'lines+markers',
'marker': {'size': 3},
'line': {'width': 2}
}
if processed_data['trace_color_mode'] == 'frequency':
# Color by frequency
trace_config['marker']['color'] = [f / 1e9 for f in frequencies]
trace_config['marker']['colorscale'] = 'Viridis'
trace_config['marker']['showscale'] = True
trace_config['marker']['colorbar'] = {'title': 'Frequency (GHz)'}
trace_config['name'] = 'S11 (colored by frequency)'
else:
trace_config['marker']['color'] = 'blue'
trace_config['name'] = 'S11'
traces = [trace_config]
# Add marker if enabled
if processed_data['marker_enabled']:
marker_real = real_parts[marker_idx]
marker_imag = imag_parts[marker_idx]
if processed_data['impedance_mode']:
marker_label = f'Z = {marker_real:.1f} + j{marker_imag:.1f} Ω'
else:
marker_label = f'Γ = {marker_real:.3f} + j{marker_imag:.3f}'
traces.append({
'x': [marker_real],
'y': [marker_imag],
'type': 'scatter',
'mode': 'markers',
'name': f'Marker: {frequencies[marker_idx]/1e9:.3f} GHz<br>{marker_label}',
'marker': {'color': 'red', 'size': 10, 'symbol': 'diamond'}
})
# Add Smith chart grid if not in impedance mode
if not processed_data['impedance_mode']:
if processed_data['grid_circles']:
traces.extend(self._generate_smith_circles())
if processed_data['grid_radials']:
traces.extend(self._generate_smith_radials())
# Layout configuration
if processed_data['impedance_mode']:
layout = {
'title': f'Impedance Plot (Z₀ = {processed_data["reference_impedance"]} Ω)',
'xaxis': {'title': 'Resistance (Ω)', 'showgrid': True, 'zeroline': True},
'yaxis': {'title': 'Reactance (Ω)', 'showgrid': True, 'zeroline': True, 'scaleanchor': 'x'},
'hovermode': 'closest'
}
else:
layout = {
'title': 'Smith Chart',
'xaxis': {
'title': 'Real Part',
'range': [-1.1, 1.1],
'showgrid': False,
'zeroline': False,
'showticklabels': True
},
'yaxis': {
'title': 'Imaginary Part',
'range': [-1.1, 1.1],
'showgrid': False,
'zeroline': False,
'scaleanchor': 'x',
'scaleratio': 1
},
'hovermode': 'closest'
}
layout['showlegend'] = True
return {
'data': traces,
'layout': layout
}
def _generate_smith_circles(self) -> List[Dict[str, Any]]:
circles = []
# Constant resistance circles
for r in [0, 0.2, 0.5, 1, 2, 5]:
if r == 0:
# Unit circle
theta = np.linspace(0, 2*np.pi, 100)
x = np.cos(theta)
y = np.sin(theta)
else:
center_x = r / (r + 1)
radius = 1 / (r + 1)
theta = np.linspace(0, 2*np.pi, 100)
x = center_x + radius * np.cos(theta)
y = radius * np.sin(theta)
circles.append({
'x': x.tolist(),
'y': y.tolist(),
'type': 'scatter',
'mode': 'lines',
'line': {'color': 'lightgray', 'width': 1},
'showlegend': False,
'hoverinfo': 'skip'
})
# Constant reactance circles
for x in [-5, -2, -1, -0.5, -0.2, 0.2, 0.5, 1, 2, 5]:
if abs(x) < 1e-10:
continue
center_y = 1/x
radius = abs(1/x)
theta = np.linspace(-np.pi, np.pi, 100)
circle_x = 1 + radius * np.cos(theta)
circle_y = center_y + radius * np.sin(theta)
# Clip to unit circle
valid_points = circle_x**2 + circle_y**2 <= 1.01
circle_x = circle_x[valid_points]
circle_y = circle_y[valid_points]
if len(circle_x) > 0:
circles.append({
'x': circle_x.tolist(),
'y': circle_y.tolist(),
'type': 'scatter',
'mode': 'lines',
'line': {'color': 'lightblue', 'width': 1},
'showlegend': False,
'hoverinfo': 'skip'
})
return circles
def _generate_smith_radials(self) -> List[Dict[str, Any]]:
radials = []
# Radial lines for constant phase
for angle_deg in range(0, 360, 30):
angle_rad = np.radians(angle_deg)
x = [0, np.cos(angle_rad)]
y = [0, np.sin(angle_rad)]
radials.append({
'x': x,
'y': y,
'type': 'scatter',
'mode': 'lines',
'line': {'color': 'lightgray', 'width': 1, 'dash': 'dot'},
'showlegend': False,
'hoverinfo': 'skip'
})
return radials
def get_ui_parameters(self) -> List[UIParameter]:
return [
UIParameter(
name='impedance_mode',
label='Impedance Plot Mode',
type='toggle',
value=self._config.get('impedance_mode', False)
),
UIParameter(
name='reference_impedance',
label='Reference Impedance (Ω)',
type='select',
value=self._config.get('reference_impedance', 50),
options={'choices': [25, 50, 75, 100, 600]}
),
UIParameter(
name='marker_enabled',
label='Show Marker',
type='toggle',
value=self._config.get('marker_enabled', True)
),
UIParameter(
name='marker_frequency',
label='Marker Frequency (Hz)',
type='input',
value=self._config.get('marker_frequency', 1e9),
options={'type': 'number', 'min': 100e6, 'max': 8.8e9}
),
UIParameter(
name='grid_circles',
label='Show Impedance Circles',
type='toggle',
value=self._config.get('grid_circles', True)
),
UIParameter(
name='grid_radials',
label='Show Phase Radials',
type='toggle',
value=self._config.get('grid_radials', True)
),
UIParameter(
name='trace_color_mode',
label='Trace Color Mode',
type='select',
value=self._config.get('trace_color_mode', 'frequency'),
options={'choices': ['frequency', 'solid']}
)
]
def _get_default_config(self) -> Dict[str, Any]:
return {
'impedance_mode': False,
'reference_impedance': 50,
'marker_enabled': True,
'marker_frequency': 1e9,
'grid_circles': True,
'grid_radials': True,
'trace_color_mode': 'frequency'
}
def _validate_config(self):
required_keys = ['impedance_mode', 'reference_impedance', 'marker_enabled',
'marker_frequency', 'grid_circles', 'grid_radials', 'trace_color_mode']
for key in required_keys:
if key not in self._config:
raise ValueError(f"Missing required config key: {key}")
if self._config['reference_impedance'] <= 0:
raise ValueError("reference_impedance must be positive")
if self._config['trace_color_mode'] not in ['frequency', 'solid']:
raise ValueError("trace_color_mode must be 'frequency' or 'solid'")

View File

@ -0,0 +1,189 @@
from typing import Dict, List, Optional, Any, Callable
import threading
import logging
from pathlib import Path
from vna_system.core.settings.preset_manager import ConfigPreset
from vna_system.core.processors.base_processor import BaseProcessor, ProcessedResult
from vna_system.core.processors.calibration_processor import CalibrationProcessor
from vna_system.core.acquisition.sweep_buffer import SweepBuffer, SweepData
from vna_system.core.settings.settings_manager import VNASettingsManager
class ProcessorManager:
def __init__(self, sweep_buffer: SweepBuffer, settings_manager: VNASettingsManager, config_dir: Path):
self.config_dir = config_dir
self._processors: Dict[str, BaseProcessor] = {}
self._lock = threading.RLock()
self._result_callbacks: List[Callable[[str, ProcessedResult], None]] = []
self.logger = logging.getLogger(__name__)
# Data acquisition integration
self.sweep_buffer: SweepBuffer = sweep_buffer
self._running = False
self._thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self._last_processed_sweep = 0
# Calibration processor for applying calibrations
self.calibration_processor = CalibrationProcessor()
self.settings_manager = settings_manager
# Register default processors
self._register_default_processors()
def register_processor(self, processor: BaseProcessor):
with self._lock:
self._processors[processor.processor_id] = processor
self.logger.info(f"Registered processor: {processor.processor_id}")
def get_processor(self, processor_id: str) -> Optional[BaseProcessor]:
return self._processors.get(processor_id)
def list_processors(self) -> List[str]:
return list(self._processors.keys())
def add_result_callback(self, callback: Callable[[str, ProcessedResult], None]):
print("adding callback")
self._result_callbacks.append(callback)
def process_sweep(self, sweep_data: SweepData, calibrated_data: Any, vna_config: ConfigPreset | None):
results = {}
print(f"Processing sweep {sweep_data.sweep_number=}")
with self._lock:
for processor_id, processor in self._processors.items():
try:
result = processor.add_sweep_data(sweep_data, calibrated_data, vna_config)
if result:
results[processor_id] = result
for callback in self._result_callbacks:
try:
callback(processor_id, result)
except Exception as e:
self.logger.error(f"Callback error for {processor_id}: {e}")
except Exception as e:
self.logger.error(f"Processing error in {processor_id}: {e}")
return results
def recalculate_processor(self, processor_id: str, config_updates: Optional[Dict[str, Any]] = None) -> Optional[ProcessedResult]:
processor = self.get_processor(processor_id)
if not processor:
raise ValueError(f"Processor {processor_id} not found")
try:
if config_updates:
processor.update_config(config_updates)
result = processor.recalculate()
if result:
for callback in self._result_callbacks:
try:
callback(processor_id, result)
except Exception as e:
self.logger.error(f"Callback error for {processor_id}: {e}")
return result
except Exception as e:
self.logger.error(f"Recalculation error in {processor_id}: {e}")
raise
def get_processor_ui_parameters(self, processor_id: str):
processor = self.get_processor(processor_id)
if not processor:
raise ValueError(f"Processor {processor_id} not found")
return [param.__dict__ for param in processor.get_ui_parameters()]
def _register_default_processors(self):
"""Register default processors"""
try:
from .implementations import MagnitudeProcessor, PhaseProcessor, SmithChartProcessor
magnitude_processor = MagnitudeProcessor(self.config_dir)
self.register_processor(magnitude_processor)
phase_processor = PhaseProcessor(self.config_dir)
self.register_processor(phase_processor)
smith_processor = SmithChartProcessor(self.config_dir)
self.register_processor(smith_processor)
self.logger.info("Default processors registered successfully")
except Exception as e:
self.logger.error(f"Failed to register default processors: {e}")
def set_sweep_buffer(self, sweep_buffer: SweepBuffer):
"""Set the sweep buffer for data acquisition integration"""
self.sweep_buffer = sweep_buffer
def start_processing(self):
"""Start background processing of sweep data"""
if self._running or not self.sweep_buffer:
return
self._running = True
self._stop_event.clear()
self._thread = threading.Thread(target=self._processing_loop, daemon=True)
self._thread.start()
self.logger.info("Processor manager started")
def stop_processing(self):
"""Stop background processing"""
if not self._running:
return
self._running = False
self._stop_event.set()
if self._thread:
self._thread.join(timeout=1.0)
self.logger.info("Processor manager stopped")
def _processing_loop(self):
"""Main processing loop"""
while self._running and not self._stop_event.is_set():
try:
latest_sweep = self.sweep_buffer.get_latest_sweep()
if latest_sweep and latest_sweep.sweep_number > self._last_processed_sweep:
# Apply calibration
calibrated_data = self._apply_calibration(latest_sweep)
# Get VNA configuration
vna_config = self.settings_manager.get_current_preset()
# Process through all processors (results handled by callbacks)
self.process_sweep(latest_sweep, calibrated_data, vna_config)
self._last_processed_sweep = latest_sweep.sweep_number
# Check every 50ms
self._stop_event.wait(0.05)
except Exception as e:
self.logger.error(f"Error in processing loop: {e}")
self._stop_event.wait(0.1)
def _apply_calibration(self, sweep_data: SweepData) -> SweepData:
"""Apply calibration to sweep data"""
try:
# Get current calibration set through settings manager
calibration_set = self.settings_manager.get_current_calibration()
if calibration_set and calibration_set.is_complete():
# Apply calibration using our calibration processor
calibrated_points = self.calibration_processor.apply_calibration(sweep_data, calibration_set)
return SweepData(
sweep_number=sweep_data.sweep_number,
timestamp=sweep_data.timestamp,
points=calibrated_points,
total_points=len(calibrated_points)
)
except Exception as e:
self.logger.error(f"Calibration failed: {e}")
# Return original data if calibration fails or not available
return sweep_data

View File

@ -0,0 +1,3 @@
from .data_storage import DataStorage, StorageEntry
__all__ = ['DataStorage', 'StorageEntry']

View File

@ -0,0 +1,162 @@
from typing import Dict, Optional, List, Any
from dataclasses import dataclass, asdict
import threading
from datetime import datetime
from collections import defaultdict, deque
import json
from pathlib import Path
from ..base_processor import ProcessedResult
@dataclass
class StorageEntry:
result: ProcessedResult
stored_at: float
class DataStorage:
def __init__(self, max_entries_per_processor: int = 100, enable_persistence: bool = False, storage_dir: Optional[Path] = None):
self.max_entries = max_entries_per_processor
self.enable_persistence = enable_persistence
self.storage_dir = storage_dir
self._data: Dict[str, deque] = defaultdict(lambda: deque(maxlen=self.max_entries))
self._lock = threading.RLock()
if self.enable_persistence and self.storage_dir:
self.storage_dir.mkdir(parents=True, exist_ok=True)
def store_result(self, processor_id: str, result: ProcessedResult):
with self._lock:
entry = StorageEntry(
result=result,
stored_at=datetime.now().timestamp()
)
self._data[processor_id].append(entry)
if self.enable_persistence:
self._persist_entry(processor_id, entry)
def get_latest_result(self, processor_id: str) -> Optional[ProcessedResult]:
with self._lock:
processor_data = self._data.get(processor_id)
if processor_data:
return processor_data[-1].result
return None
def get_results_history(self, processor_id: str, limit: Optional[int] = None) -> List[ProcessedResult]:
with self._lock:
processor_data = self._data.get(processor_id, deque())
entries = list(processor_data)
if limit:
entries = entries[-limit:]
return [entry.result for entry in entries]
def get_all_latest_results(self) -> Dict[str, ProcessedResult]:
results = {}
with self._lock:
for processor_id in self._data:
latest = self.get_latest_result(processor_id)
if latest:
results[processor_id] = latest
return results
def clear_processor_data(self, processor_id: str):
with self._lock:
if processor_id in self._data:
self._data[processor_id].clear()
def get_processor_count(self, processor_id: str) -> int:
with self._lock:
return len(self._data.get(processor_id, deque()))
def get_storage_stats(self) -> Dict[str, Any]:
with self._lock:
stats = {
'total_processors': len(self._data),
'max_entries_per_processor': self.max_entries,
'enable_persistence': self.enable_persistence,
'processors': {}
}
for processor_id, entries in self._data.items():
stats['processors'][processor_id] = {
'count': len(entries),
'oldest_entry': entries[0].stored_at if entries else None,
'latest_entry': entries[-1].stored_at if entries else None
}
return stats
def _persist_entry(self, processor_id: str, entry: StorageEntry):
if not self.storage_dir:
return
processor_dir = self.storage_dir / processor_id
processor_dir.mkdir(exist_ok=True)
timestamp = datetime.fromtimestamp(entry.stored_at).strftime('%Y%m%d_%H%M%S_%f')
filename = f"{timestamp}.json"
filepath = processor_dir / filename
try:
data = {
'stored_at': entry.stored_at,
'processor_id': entry.result.processor_id,
'timestamp': entry.result.timestamp,
'data': entry.result.data,
'plotly_config': entry.result.plotly_config,
'ui_parameters': [asdict(param) for param in entry.result.ui_parameters],
'metadata': entry.result.metadata
}
with open(filepath, 'w') as f:
json.dump(data, f, indent=2, default=str)
except Exception as e:
# Log error but don't fail the storage operation
pass
def load_persisted_data(self, processor_id: str, limit: Optional[int] = None) -> List[ProcessedResult]:
if not self.enable_persistence or not self.storage_dir:
return []
processor_dir = self.storage_dir / processor_id
if not processor_dir.exists():
return []
results = []
json_files = sorted(processor_dir.glob('*.json'))
if limit:
json_files = json_files[-limit:]
for filepath in json_files:
try:
with open(filepath, 'r') as f:
data = json.load(f)
# Reconstruct UIParameter objects
ui_parameters = []
for param_data in data.get('ui_parameters', []):
from ..base_processor import UIParameter
ui_parameters.append(UIParameter(**param_data))
result = ProcessedResult(
processor_id=data['processor_id'],
timestamp=data['timestamp'],
data=data['data'],
plotly_config=data['plotly_config'],
ui_parameters=ui_parameters,
metadata=data['metadata']
)
results.append(result)
except Exception as e:
# Skip corrupted files
continue
return results

View File

@ -0,0 +1,224 @@
from typing import Dict, Any, Set, Optional
import json
import asyncio
import logging
from datetime import datetime
from fastapi import WebSocket, WebSocketDisconnect
from vna_system.core.processors.base_processor import ProcessedResult
from vna_system.core.processors.manager import ProcessorManager
from vna_system.core.processors.storage import DataStorage
class ProcessorWebSocketHandler:
"""
Handles incoming websocket messages and broadcasts processor results
to all connected clients. Safe to call from non-async threads via
_broadcast_result_sync().
"""
def __init__(self, processor_manager: ProcessorManager, data_storage: DataStorage):
self.processor_manager = processor_manager
self.data_storage = data_storage
self.active_connections: Set[WebSocket] = set()
self.logger = logging.getLogger(__name__)
# Главный (running) event loop FastAPI/uvicorn.
# Устанавливается при принятии первого соединения.
self._loop: Optional[asyncio.AbstractEventLoop] = None
# Регистрируемся как колбэк на готовые результаты процессоров
self.processor_manager.add_result_callback(self._on_processor_result)
# --------------- Публичные async-обработчики входящих сообщений ---------------
async def handle_websocket_connection(self, websocket: WebSocket):
"""
Accepts a websocket and serves messages until disconnect.
Сохраняет ссылку на главный running loop, чтобы из других потоков
можно было безопасно шедулить корутины.
"""
# Сохраним ссылку на активный loop (гарантированно внутри async-контекста)
if self._loop is None:
try:
self._loop = asyncio.get_running_loop()
self.logger.info("Stored main event loop reference for broadcasting")
except RuntimeError:
# Теоретически маловероятно здесь
self.logger.warning("Could not obtain running loop; broadcasts may be skipped")
await websocket.accept()
self.active_connections.add(websocket)
self.logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}")
try:
while True:
data = await websocket.receive_text()
await self.handle_message(websocket, data)
except WebSocketDisconnect:
await self.disconnect(websocket)
except Exception as e:
self.logger.error(f"WebSocket error: {e}")
await self.disconnect(websocket)
async def handle_message(self, websocket: WebSocket, data: str):
try:
message = json.loads(data)
message_type = message.get('type')
if message_type == 'recalculate':
await self._handle_recalculate(websocket, message)
elif message_type == 'get_history':
await self._handle_get_history(websocket, message)
else:
await self._send_error(websocket, f"Unknown message type: {message_type}")
except json.JSONDecodeError:
await self._send_error(websocket, "Invalid JSON format")
except Exception as e:
self.logger.exception("Error handling websocket message")
await self._send_error(websocket, f"Internal error: {str(e)}")
async def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
self.logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}")
# --------------- Команды клиента ---------------
async def _handle_recalculate(self, websocket: WebSocket, message: Dict[str, Any]):
processor_id = message.get('processor_id')
config_updates = message.get('config_updates')
if not processor_id:
await self._send_error(websocket, "processor_id is required")
return
try:
result = self.processor_manager.recalculate_processor(processor_id, config_updates)
if result:
response = self._result_to_message(processor_id, result)
await websocket.send_text(json.dumps(response))
else:
await self._send_error(websocket, f"No result from processor {processor_id}")
except Exception as e:
self.logger.exception("Recalculation failed")
await self._send_error(websocket, f"Recalculation failed: {str(e)}")
async def _handle_get_history(self, websocket: WebSocket, message: Dict[str, Any]):
processor_id = message.get('processor_id')
limit = message.get('limit', 10)
if not processor_id:
await self._send_error(websocket, "processor_id is required")
return
try:
history = self.data_storage.get_results_history(processor_id, limit)
response = {
'type': 'processor_history',
'processor_id': processor_id,
'history': [
{
'timestamp': r.timestamp,
'data': r.data,
'plotly_config': r.plotly_config,
'metadata': r.metadata
}
for r in history
]
}
await websocket.send_text(json.dumps(response))
except Exception as e:
self.logger.exception("Error getting history")
await self._send_error(websocket, f"Error getting history: {str(e)}")
# --------------- Служебные методы ---------------
def _result_to_message(self, processor_id: str, result: ProcessedResult) -> Dict[str, Any]:
return {
'type': 'processor_result',
'processor_id': processor_id,
'timestamp': result.timestamp,
'data': result.data,
'plotly_config': result.plotly_config,
'ui_parameters': [param.__dict__ for param in result.ui_parameters],
'metadata': result.metadata
}
async def _send_error(self, websocket: WebSocket, message: str):
try:
response = {
'type': 'error',
'message': message,
'timestamp': datetime.now().timestamp()
}
await websocket.send_text(json.dumps(response))
except Exception as e:
self.logger.error(f"Error sending error message: {e}")
# --------------- Получение результатов из процессоров (из другого потока) ---------------
def _on_processor_result(self, processor_id: str, result: ProcessedResult):
"""
Колбэк вызывается из потока обработки свипов (не из asyncio loop).
Здесь нельзя напрямую await'ить — нужно перепоручить рассылку в главный loop.
"""
# Сохраняем результат в хранилище (синхронно)
try:
self.data_storage.store_result(processor_id, result)
except Exception:
self.logger.exception("Failed to store processor result")
# Рассылаем клиентам
self._broadcast_result_sync(processor_id, result)
def _broadcast_result_sync(self, processor_id: str, result: ProcessedResult):
"""
Потокобезопасная рассылка в активный event loop.
Вызывается из НЕ-async потока.
"""
if not self.active_connections:
return
# Подготовим строку JSON один раз
message_str = json.dumps(self._result_to_message(processor_id, result))
loop = self._loop
if loop is None or not loop.is_running():
# Луп ещё не был сохранён (нет подключений) или уже остановлен
self.logger.debug("No running event loop available for broadcast; skipping")
return
try:
# Перекидываем корутину в главный loop из стороннего потока
fut = asyncio.run_coroutine_threadsafe(self._send_to_connections(message_str), loop)
# Опционально: можно добавить обработку результата/исключений:
# fut.add_done_callback(lambda f: f.exception() and self.logger.error(f"Broadcast error: {f.exception()}"))
except Exception as e:
self.logger.error(f"Failed to schedule broadcast: {e}")
async def _send_to_connections(self, message_str: str):
"""
Реальная рассылка по всем активным соединениям (внутри event loop).
"""
if not self.active_connections:
return
disconnected = []
# Снимок, чтобы итерация была стабильной
for websocket in list(self.active_connections):
try:
await websocket.send_text(message_str)
except Exception as e:
self.logger.error(f"Error broadcasting to a websocket: {e}")
disconnected.append(websocket)
# Очистим отключившиеся
for websocket in disconnected:
try:
await self.disconnect(websocket)
except Exception as e:
self.logger.error(f"Error during disconnect cleanup: {e}")

View File

@ -7,9 +7,9 @@ from enum import Enum
from pathlib import Path
from typing import Dict, List
from vna_system.config import config as cfg
from vna_system.core import config as cfg
from vna_system.core.acquisition.sweep_buffer import SweepData
from .preset_manager import ConfigPreset, VNAMode, PresetManager
from .preset_manager import ConfigPreset, VNAMode
class CalibrationStandard(Enum):
@ -64,7 +64,7 @@ class CalibrationSet:
class CalibrationManager:
def __init__(self, preset_manager: PresetManager, base_dir: Path | None = None):
def __init__(self, base_dir: Path | None = None):
self.base_dir = Path(base_dir or cfg.BASE_DIR)
self.calibration_dir = self.base_dir / "calibration"
self.current_calibration_symlink = self.calibration_dir / "current_calibration"
@ -74,9 +74,6 @@ class CalibrationManager:
# Current working calibration set
self._current_working_set: CalibrationSet | None = None
# Preset manager for parsing filenames
self.preset_manager = preset_manager
def start_new_calibration(self, preset: ConfigPreset) -> CalibrationSet:
"""Start new calibration set for preset"""
self._current_working_set = CalibrationSet(preset)
@ -266,7 +263,7 @@ class CalibrationManager:
self.current_calibration_symlink.symlink_to(relative_path)
def get_current_calibration(self) -> CalibrationSet | None:
def get_current_calibration(self, current_preset: ConfigPreset) -> CalibrationSet | None:
"""Get currently selected calibration as CalibrationSet"""
if not self.current_calibration_symlink.exists():
return None
@ -276,14 +273,12 @@ class CalibrationManager:
calibration_name = target.name
preset_name = target.parent.name
# Parse preset from filename
preset = self.preset_manager._parse_filename(f"{preset_name}.bin")
# If current_preset matches, use it
if current_preset.filename == f"{preset_name}.bin":
return self.load_calibration_set(current_preset, calibration_name)
else:
raise RuntimeError("Current calibration is set and is meant for different preset.")
if preset is None:
return None
# Load and return the calibration set
return self.load_calibration_set(preset, calibration_name)
except Exception:
return None

View File

@ -6,7 +6,7 @@ from enum import Enum
from pathlib import Path
from typing import List
from vna_system.config import config as cfg
from vna_system.core import config as cfg
class VNAMode(Enum):

View File

@ -4,7 +4,7 @@ import logging
from pathlib import Path
from typing import Dict, List
from vna_system.config import config as cfg
from vna_system.core import config as cfg
from vna_system.core.acquisition.sweep_buffer import SweepData
from .preset_manager import PresetManager, ConfigPreset, VNAMode
from .calibration_manager import CalibrationManager, CalibrationSet, CalibrationStandard
@ -27,7 +27,7 @@ class VNASettingsManager:
# Initialize sub-managers
self.preset_manager = PresetManager(self.base_dir / "binary_input")
self.calibration_manager = CalibrationManager(self.preset_manager, self.base_dir)
self.calibration_manager = CalibrationManager(self.base_dir)
# ---------- Preset Management ----------
@ -96,7 +96,11 @@ class VNASettingsManager:
def get_current_calibration(self) -> CalibrationSet | None:
"""Get currently selected calibration set (saved and active via symlink)"""
return self.calibration_manager.get_current_calibration()
current_preset = self.get_current_preset()
if current_preset is not None:
return self.calibration_manager.get_current_calibration(current_preset)
else:
return None
def get_calibration_info(self, calibration_name: str, preset: ConfigPreset | None = None) -> Dict:
"""Get calibration information"""

View File

@ -5,13 +5,21 @@ Global singleton instances for the VNA system.
This module centralizes all singleton instances to avoid global variables
scattered throughout the codebase.
"""
from pathlib import Path
from vna_system.core.acquisition.data_acquisition import VNADataAcquisition
from vna_system.core.processing.sweep_processor import SweepProcessingManager
from vna_system.core.processing.websocket_handler import WebSocketManager
from vna_system.core.processors.storage.data_storage import DataStorage
from vna_system.core.settings.settings_manager import VNASettingsManager
from vna_system.core.processors.manager import ProcessorManager
from vna_system.core.processors.websocket_handler import ProcessorWebSocketHandler
# Global singleton instances
vna_data_acquisition_instance: VNADataAcquisition = VNADataAcquisition()
processing_manager: SweepProcessingManager = SweepProcessingManager()
websocket_manager: WebSocketManager = WebSocketManager(processing_manager)
settings_manager: VNASettingsManager = VNASettingsManager()
settings_manager: VNASettingsManager = VNASettingsManager()
# Processor system
processor_config_dir = Path("vna_system/core/processors/configs")
processor_manager: ProcessorManager = ProcessorManager(vna_data_acquisition_instance.sweep_buffer, settings_manager, processor_config_dir)
data_storage = DataStorage()
processor_websocket_handler: ProcessorWebSocketHandler = ProcessorWebSocketHandler(
processor_manager, data_storage
)

View File

@ -89,4 +89,6 @@ log_info "Press Ctrl+C to stop the server"
echo
# Run the main application
exec python3 -m vna_system.api.main
exec python3 -m vna_system.api.main

View File

@ -0,0 +1,81 @@
/* Acquisition Controls */
.acquisition-controls {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.acquisition-status {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-2-5);
background-color: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
}
.acquisition-mode {
display: flex;
align-items: center;
gap: var(--space-1-5);
}
.mode-label {
color: var(--text-secondary);
font-weight: var(--font-weight-medium);
}
.mode-value {
color: var(--text-primary);
font-weight: var(--font-weight-semibold);
}
/* Status indicator variations */
.status-indicator__dot--idle {
background-color: var(--color-gray-400);
}
.status-indicator__dot--running {
background-color: var(--color-success-500);
animation: pulse 2s infinite;
}
.status-indicator__dot--paused {
background-color: var(--color-warning-500);
}
.status-indicator__dot--error {
background-color: var(--color-error-500);
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
/* Button accent variant */
.btn--accent {
background-color: var(--color-accent-600, #7c3aed);
color: white;
}
.btn--accent:hover {
background-color: var(--color-accent-700, #6d28d9);
}
.btn--accent.btn--bordered {
border-color: var(--color-accent-500, #8b5cf6);
box-shadow: 0 0 0 1px var(--color-accent-600, #7c3aed), var(--shadow-sm);
}
.btn--accent.btn--bordered:hover {
box-shadow: 0 0 0 1px var(--color-accent-500, #8b5cf6), var(--shadow-md);
transform: translateY(-1px);
}

View File

@ -223,6 +223,7 @@
display: flex;
align-items: center;
justify-content: space-between;
border-radius: var(--radius-xl);
padding: var(--space-4) var(--space-5);
background-color: var(--bg-tertiary);
border-bottom: 1px solid var(--border-primary);
@ -272,16 +273,30 @@
}
.chart-card__content {
display: flex;
position: relative;
padding: var(--space-4);
min-height: 450px;
gap: var(--space-4);
}
.chart-card__plot {
width: 100% !important;
flex: 1;
height: 420px !important;
min-height: 400px;
position: relative;
min-width: 0; /* Allows flex item to shrink */
}
.chart-card__settings {
width: 250px;
flex-shrink: 0;
background-color: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-3);
overflow-y: auto;
max-height: 420px;
}
.chart-card__plot .js-plotly-plot {
@ -294,12 +309,85 @@
height: 100% !important;
}
/* Fix Plotly toolbar positioning with project design system */
.chart-card__plot .modebar {
position: absolute !important;
top: var(--space-3) !important;
right: var(--space-3) !important;
z-index: 1000 !important;
background: var(--bg-surface) !important;
border: 1px solid var(--border-primary) !important;
border-radius: var(--radius-lg) !important;
padding: var(--space-1) !important;
box-shadow: var(--shadow-md) !important;
backdrop-filter: blur(8px) !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
gap: var(--space-1) !important;
}
.chart-card__plot .modebar-group {
display: flex !important;
flex-direction: row !important;
margin: 0 !important;
padding: 0 !important;
}
.chart-card__plot .modebar-group:not(:last-child)::after {
content: '' !important;
width: 1px !important;
height: 16px !important;
background: var(--border-primary) !important;
margin: 0 var(--space-1) !important;
}
.chart-card__plot .modebar-btn {
width: 28px !important;
height: 28px !important;
margin: 0 !important;
padding: var(--space-1-5) !important;
border: none !important;
background: transparent !important;
border-radius: var(--radius-default) !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
transition: all var(--transition-fast) !important;
color: var(--text-secondary) !important;
}
.chart-card__plot .modebar-btn:hover {
background: var(--bg-surface-hover) !important;
color: var(--text-primary) !important;
transform: translateY(-1px) !important;
}
.chart-card__plot .modebar-btn svg {
width: 14px !important;
height: 14px !important;
fill: currentColor !important;
transition: fill var(--transition-fast) !important;
}
.chart-card__plot .modebar-btn--active {
background: var(--color-primary-600) !important;
color: white !important;
}
.chart-card__plot .modebar-btn--active:hover {
background: var(--color-primary-700) !important;
transform: translateY(-1px) !important;
}
.chart-card__meta {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) var(--space-5);
background-color: var(--bg-primary);
border-radius: var(--radius-xl);
border-top: 1px solid var(--border-primary);
font-size: var(--font-size-xs);
color: var(--text-tertiary);
@ -458,4 +546,357 @@
.form-input::placeholder {
color: var(--text-tertiary);
}
/* Processor Settings */
.processor-settings {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.processor-settings--empty {
padding: var(--space-4);
text-align: center;
color: var(--text-tertiary);
font-size: var(--font-size-sm);
}
.processor-config {
background-color: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-3);
}
.processor-config__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
cursor: pointer;
user-select: none;
}
.processor-config__title {
display: flex;
align-items: center;
gap: var(--space-2);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
font-size: var(--font-size-sm);
}
.processor-config__icon {
width: 16px;
height: 16px;
color: var(--color-primary-500);
}
.processor-config__toggle {
width: 16px;
height: 16px;
color: var(--text-tertiary);
transition: all var(--transition-fast);
transform: rotate(0deg);
}
.processor-config--expanded .processor-config__toggle {
transform: rotate(180deg);
}
.processor-config__content {
display: none;
grid-template-columns: 1fr;
gap: var(--space-3);
}
.processor-config--expanded .processor-config__content {
display: grid;
}
.processor-param {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.processor-param__label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--text-secondary);
}
.processor-param__slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
background-color: var(--bg-primary);
border-radius: var(--radius-full);
outline: none;
cursor: pointer;
}
.processor-param__slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background-color: var(--color-primary-500);
border-radius: var(--radius-full);
cursor: pointer;
border: 2px solid var(--bg-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.processor-param__slider::-moz-range-thumb {
width: 18px;
height: 18px;
background-color: var(--color-primary-500);
border-radius: var(--radius-full);
cursor: pointer;
border: 2px solid var(--bg-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.processor-param__toggle {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.processor-param__toggle input {
opacity: 0;
width: 0;
height: 0;
}
.processor-param__toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-primary);
border: 1px solid var(--border-primary);
transition: var(--transition-fast);
border-radius: var(--radius-full);
}
.processor-param__toggle-slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background-color: var(--text-tertiary);
transition: var(--transition-fast);
border-radius: var(--radius-full);
}
input:checked + .processor-param__toggle-slider {
background-color: var(--color-primary-500);
border-color: var(--color-primary-500);
}
input:checked + .processor-param__toggle-slider:before {
transform: translateX(20px);
background-color: white;
}
.processor-param__select {
padding: var(--space-2) var(--space-3);
background-color: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-default);
font-size: var(--font-size-sm);
color: var(--text-primary);
cursor: pointer;
transition: all var(--transition-fast);
}
.processor-param__select:focus {
outline: none;
border-color: var(--color-primary-500);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.processor-param__value {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
text-align: right;
}
/* Chart Settings Styles */
.chart-settings {
height: 100%;
display: flex;
flex-direction: column;
}
.chart-settings__header {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--border-primary);
}
.chart-settings__controls {
display: flex;
flex-direction: column;
gap: var(--space-3);
flex: 1;
}
.settings-empty {
color: var(--text-tertiary);
font-size: var(--font-size-sm);
text-align: center;
padding: var(--space-4);
}
.chart-setting {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.chart-setting__label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--text-secondary);
display: flex;
justify-content: space-between;
align-items: center;
}
.chart-setting__value {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
font-weight: var(--font-weight-normal);
}
.chart-setting__slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
background-color: var(--bg-primary);
border-radius: var(--radius-full);
outline: none;
cursor: pointer;
}
.chart-setting__slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background-color: var(--color-primary-500);
border-radius: var(--radius-full);
cursor: pointer;
border: 2px solid var(--bg-secondary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.chart-setting__toggle {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
}
.chart-setting__toggle input {
position: relative;
width: 36px;
height: 20px;
-webkit-appearance: none;
appearance: none;
background-color: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-full);
cursor: pointer;
transition: all var(--transition-fast);
}
.chart-setting__toggle input:before {
content: '';
position: absolute;
width: 16px;
height: 16px;
border-radius: var(--radius-full);
background-color: var(--text-tertiary);
transition: all var(--transition-fast);
top: 1px;
left: 1px;
}
.chart-setting__toggle input:checked {
background-color: var(--color-primary-500);
border-color: var(--color-primary-500);
}
.chart-setting__toggle input:checked:before {
transform: translateX(16px);
background-color: white;
}
.chart-setting__select,
.chart-setting__input {
padding: var(--space-2);
background-color: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-default);
font-size: var(--font-size-sm);
color: var(--text-primary);
cursor: pointer;
transition: all var(--transition-fast);
}
.chart-setting__select:focus,
.chart-setting__input:focus {
outline: none;
border-color: var(--color-primary-500);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Mobile Responsive Styles for Chart Settings */
@media (max-width: 1024px) {
.chart-card__content {
flex-direction: column;
gap: var(--space-3);
}
.chart-card__settings {
width: 100%;
max-height: 200px;
order: 1;
}
.chart-card__plot {
order: 2;
height: 350px !important;
min-height: 300px;
}
}
@media (max-width: 768px) {
.chart-card__content {
padding: var(--space-3);
}
.chart-card__plot {
height: 300px !important;
min-height: 250px;
}
.chart-card__settings {
max-height: 150px;
}
}

View File

@ -240,8 +240,8 @@ body {
}
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(550px, 1fr));
display: flex;
flex-direction: column;
gap: var(--space-6);
margin-bottom: var(--space-6);
}

View File

@ -9,6 +9,7 @@ import { UIManager } from './modules/ui.js';
import { NotificationManager } from './modules/notifications.js';
import { StorageManager } from './modules/storage.js';
import { SettingsManager } from './modules/settings.js';
import { AcquisitionManager } from './modules/acquisition.js';
/**
* Main Application Class
@ -35,13 +36,21 @@ class VNADashboard {
}
};
// Initialize managers
// Core managers (order matters now)
this.storage = new StorageManager();
this.notifications = new NotificationManager();
this.ui = new UIManager(this.notifications);
// Charts first (used by UI, but UI init не зависит от готовности charts)
this.charts = new ChartManager(this.config.charts, this.notifications);
// WebSocket before UI (UI подписывается на события ws)
this.websocket = new WebSocketManager(this.config.websocket, this.notifications);
this.settings = new SettingsManager(this.notifications);
// UI получает зависимости извне
this.ui = new UIManager(this.notifications, this.websocket, this.charts);
this.acquisition = new AcquisitionManager(this.notifications);
this.settings = new SettingsManager(this.notifications, this.websocket, this.acquisition);
// Bind methods
this.handleWebSocketData = this.handleWebSocketData.bind(this);
@ -94,11 +103,14 @@ class VNADashboard {
* Initialize all modules
*/
async initializeModules() {
// Initialize UI manager first
// Initialize chart manager (DOM containers)
await this.charts.init();
// Initialize UI manager
await this.ui.init();
// Initialize chart manager
await this.charts.init();
// Initialize acquisition manager first (required by settings)
this.acquisition.initialize();
// Initialize settings manager
await this.settings.init();
@ -109,11 +121,8 @@ class VNADashboard {
// Load and apply user preferences after UI is initialized
const preferences = await this.storage.getPreferences();
if (preferences) {
// Clean up old invalid processors from preferences
this.cleanupPreferences(preferences);
this.applyPreferences(preferences);
// Save cleaned preferences back to storage
this.savePreferences();
}
}
@ -122,17 +131,8 @@ class VNADashboard {
* Set up event listeners
*/
setupEventListeners() {
// WebSocket events
// WebSocket events - UIManager handles most of this
this.websocket.on('data', this.handleWebSocketData);
this.websocket.on('connected', () => {
this.ui.setConnectionStatus('connected');
});
this.websocket.on('disconnected', () => {
this.ui.setConnectionStatus('disconnected');
});
this.websocket.on('connecting', () => {
this.ui.setConnectionStatus('connecting');
});
// Browser events
document.addEventListener('visibilitychange', this.handleVisibilityChange);
@ -149,22 +149,19 @@ class VNADashboard {
// Navigation
this.ui.onViewChange((view) => {
console.log(`📱 Switched to view: ${view}`);
// Refresh settings when switching to settings view
if (view === 'settings') {
this.settings.refresh();
}
});
// Processor toggles
// Processor toggles (UI-only visibility)
this.ui.onProcessorToggle((processor, enabled) => {
console.log(`🔧 Processor ${processor}: ${enabled ? 'enabled' : 'disabled'}`);
this.charts.toggleProcessor(processor, enabled);
this.savePreferences();
});
// Chart clearing removed - users can disable processors individually
// Export
this.ui.onExportData(() => {
console.log('📊 Exporting chart data...');
this.exportData();
@ -172,33 +169,16 @@ class VNADashboard {
}
/**
* Handle WebSocket data
* Handle non-processor WebSocket data
*/
handleWebSocketData(data) {
try {
if (data.type === 'processing_result') {
// Add chart data
this.charts.addData(data.data);
// Update processor in UI
this.ui.updateProcessorFromData(data.data.processor_name);
// Update general stats
this.ui.updateStats({
sweepCount: data.data.sweep_number,
lastUpdate: new Date()
});
} else if (data.type === 'system_status') {
this.ui.updateSystemStatus(data.data);
}
} catch (error) {
console.error('❌ Error handling WebSocket data:', error);
this.notifications.show({
type: 'error',
title: 'Data Processing Error',
message: 'Failed to process incoming data'
});
// UIManager handles processor_result directly, we only handle other types
if (data.type === 'system_status') {
this.ui.updateSystemStatus(data.data);
} else if (data.type === 'processor_history') {
console.log('📜 Received processor history:', data);
}
// Note: 'error' is handled by WebSocket manager directly
}
/**
@ -206,10 +186,8 @@ class VNADashboard {
*/
handleVisibilityChange() {
if (document.hidden) {
// Pause expensive operations when hidden
this.charts.pause();
} else {
// Resume when visible
this.charts.resume();
}
}
@ -245,9 +223,7 @@ class VNADashboard {
exportData() {
try {
const data = this.charts.exportData();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json'
});
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@ -277,15 +253,11 @@ class VNADashboard {
* Clean up old/invalid preferences
*/
cleanupPreferences(preferences) {
// List of invalid/old processor names that should be removed
const invalidProcessors = ['sweep_counter'];
if (preferences.disabledProcessors && Array.isArray(preferences.disabledProcessors)) {
// Remove invalid processors
preferences.disabledProcessors = preferences.disabledProcessors.filter(
processor => !invalidProcessors.includes(processor)
);
if (preferences.disabledProcessors.length === 0) {
delete preferences.disabledProcessors;
}
@ -298,9 +270,9 @@ class VNADashboard {
applyPreferences(preferences) {
try {
if (preferences.disabledProcessors && Array.isArray(preferences.disabledProcessors)) {
preferences.disabledProcessors.forEach(processor => {
preferences.disabledProcessors.forEach(proc => {
if (this.ui && typeof this.ui.setProcessorEnabled === 'function') {
this.ui.setProcessorEnabled(processor, false);
this.ui.setProcessorEnabled(proc, false);
}
});
}
@ -348,14 +320,11 @@ class VNADashboard {
console.log('🧹 Cleaning up VNA Dashboard...');
// Remove event listeners
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
window.removeEventListener('beforeunload', this.handleBeforeUnload);
// Disconnect WebSocket
this.websocket.disconnect();
// Cleanup managers
this.charts.destroy();
this.settings.destroy();
this.ui.destroy();
@ -395,7 +364,6 @@ if (typeof process !== 'undefined' && process?.env?.NODE_ENV === 'development')
ui: () => window.vnaDashboard?.ui
};
} else {
// Browser environment debug helpers
window.debug = {
dashboard: () => window.vnaDashboard,
websocket: () => window.vnaDashboard?.websocket,
@ -403,4 +371,4 @@ if (typeof process !== 'undefined' && process?.env?.NODE_ENV === 'development')
ui: () => window.vnaDashboard?.ui,
settings: () => window.vnaDashboard?.settings
};
}
}

View File

@ -0,0 +1,255 @@
/**
* Acquisition Control Module
* Handles VNA data acquisition control via REST API
*/
export class AcquisitionManager {
constructor(notifications) {
this.notifications = notifications;
this.isInitialized = false;
this.currentStatus = {
running: false,
paused: false,
continuous_mode: true,
sweep_count: 0
};
// Bind methods
this.handleStartClick = this.handleStartClick.bind(this);
this.handleStopClick = this.handleStopClick.bind(this);
this.handleSingleSweepClick = this.handleSingleSweepClick.bind(this);
this.updateStatus = this.updateStatus.bind(this);
}
initialize() {
if (this.isInitialized) return;
// Get DOM elements
this.elements = {
startBtn: document.getElementById('startBtn'),
stopBtn: document.getElementById('stopBtn'),
singleSweepBtn: document.getElementById('singleSweepBtn'),
statusText: document.getElementById('acquisitionStatusText'),
statusDot: document.querySelector('.status-indicator__dot'),
modeText: document.getElementById('acquisitionModeText')
};
// Add event listeners
this.elements.startBtn?.addEventListener('click', this.handleStartClick);
this.elements.stopBtn?.addEventListener('click', this.handleStopClick);
this.elements.singleSweepBtn?.addEventListener('click', this.handleSingleSweepClick);
// Initial status update
this.updateStatus();
// Poll status every 2 seconds
this.statusInterval = setInterval(this.updateStatus, 2000);
this.isInitialized = true;
console.log('AcquisitionManager initialized');
}
async handleStartClick() {
try {
this.setButtonLoading(this.elements.startBtn, true);
const response = await fetch('/api/v1/acquisition/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const result = await response.json();
if (result.success) {
this.notifications.show(result.message, 'success');
await this.updateStatus();
} else {
this.notifications.show(result.error || 'Failed to start acquisition', 'error');
}
} catch (error) {
console.error('Error starting acquisition:', error);
this.notifications.show('Failed to start acquisition', 'error');
} finally {
this.setButtonLoading(this.elements.startBtn, false);
}
}
async handleStopClick() {
try {
this.setButtonLoading(this.elements.stopBtn, true);
const response = await fetch('/api/v1/acquisition/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const result = await response.json();
if (result.success) {
this.notifications.show(result.message, 'success');
await this.updateStatus();
} else {
this.notifications.show(result.error || 'Failed to stop acquisition', 'error');
}
} catch (error) {
console.error('Error stopping acquisition:', error);
this.notifications.show('Failed to stop acquisition', 'error');
} finally {
this.setButtonLoading(this.elements.stopBtn, false);
}
}
async handleSingleSweepClick() {
try {
this.setButtonLoading(this.elements.singleSweepBtn, true);
const response = await fetch('/api/v1/acquisition/single-sweep', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const result = await response.json();
if (result.success) {
this.notifications.show(result.message, 'success');
await this.updateStatus();
} else {
this.notifications.show(result.error || 'Failed to trigger single sweep', 'error');
}
} catch (error) {
console.error('Error triggering single sweep:', error);
this.notifications.show('Failed to trigger single sweep', 'error');
} finally {
this.setButtonLoading(this.elements.singleSweepBtn, false);
}
}
async updateStatus() {
try {
const response = await fetch('/api/v1/acquisition/status');
const status = await response.json();
this.currentStatus = status;
this.updateUI(status);
} catch (error) {
console.error('Error getting acquisition status:', error);
this.updateUI({
running: false,
paused: false,
continuous_mode: true,
sweep_count: 0
});
}
}
updateUI(status) {
// Update status text and indicator
let statusText = 'Idle';
let statusClass = 'status-indicator__dot--idle';
if (status.running) {
if (status.paused) {
statusText = 'Stopped';
statusClass = 'status-indicator__dot--paused';
} else {
statusText = 'Running';
statusClass = 'status-indicator__dot--running';
}
}
if (this.elements.statusText) {
this.elements.statusText.textContent = statusText;
}
if (this.elements.statusDot) {
this.elements.statusDot.className = `status-indicator__dot ${statusClass}`;
}
// Update mode text
if (this.elements.modeText) {
this.elements.modeText.textContent = status.continuous_mode ? 'Continuous' : 'Single';
}
// Update button states
if (this.elements.startBtn) {
this.elements.startBtn.disabled = status.running && !status.paused;
}
if (this.elements.stopBtn) {
this.elements.stopBtn.disabled = !status.running || status.paused;
}
if (this.elements.singleSweepBtn) {
this.elements.singleSweepBtn.disabled = !status.running;
}
// Update sweep count in header if available
const sweepCountEl = document.getElementById('sweepCount');
if (sweepCountEl) {
sweepCountEl.textContent = status.sweep_count || 0;
}
}
setButtonLoading(button, loading) {
if (!button) return;
if (loading) {
button.disabled = true;
button.classList.add('loading');
const icon = button.querySelector('i');
if (icon) {
icon.setAttribute('data-lucide', 'loader-2');
icon.style.animation = 'spin 1s linear infinite';
// Re-initialize lucide for the changed icon
if (window.lucide) {
window.lucide.createIcons();
}
}
} else {
button.disabled = false;
button.classList.remove('loading');
const icon = button.querySelector('i');
if (icon) {
icon.style.animation = '';
// Restore original icon
const buttonId = button.id;
const originalIcons = {
'startBtn': 'play',
'stopBtn': 'square',
'singleSweepBtn': 'zap'
};
const originalIcon = originalIcons[buttonId];
if (originalIcon) {
icon.setAttribute('data-lucide', originalIcon);
if (window.lucide) {
window.lucide.createIcons();
}
}
}
}
}
// Public method to trigger single sweep programmatically
async triggerSingleSweep() {
return await this.handleSingleSweepClick();
}
// Public method to get current acquisition status
isRunning() {
return this.currentStatus.running && !this.currentStatus.paused;
}
destroy() {
if (this.statusInterval) {
clearInterval(this.statusInterval);
}
// Remove event listeners
this.elements?.startBtn?.removeEventListener('click', this.handleStartClick);
this.elements?.stopBtn?.removeEventListener('click', this.handleStopClick);
this.elements?.singleSweepBtn?.removeEventListener('click', this.handleSingleSweepClick);
this.isInitialized = false;
console.log('AcquisitionManager destroyed');
}
}

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ export class NotificationManager {
this.container = null;
this.notifications = new Map(); // id -> notification element
this.nextId = 1;
this.recentNotifications = new Map(); // title+message -> timestamp for deduplication
// Configuration
this.config = {
@ -65,6 +66,26 @@ export class NotificationManager {
* Show a notification
*/
show(options) {
// Check for duplicate notifications within the last 2 seconds
const { type = 'info', title, message } = options;
const notificationKey = `${type}:${title}:${message}`;
const now = Date.now();
const lastShown = this.recentNotifications.get(notificationKey);
if (lastShown && (now - lastShown) < 2000) {
console.log('🔄 Skipping duplicate notification:', notificationKey);
return null; // Skip duplicate notification
}
this.recentNotifications.set(notificationKey, now);
// Clean up old entries (older than 5 seconds)
for (const [key, timestamp] of this.recentNotifications) {
if (now - timestamp > 5000) {
this.recentNotifications.delete(key);
}
}
const notification = this.createNotification(options);
this.addNotification(notification);
return notification.id;

View File

@ -4,13 +4,20 @@
*/
export class SettingsManager {
constructor(notifications) {
constructor(notifications, websocket, acquisition) {
this.notifications = notifications;
this.websocket = websocket;
this.acquisition = acquisition;
this.isInitialized = false;
this.currentPreset = null;
this.currentCalibration = null;
this.workingCalibration = null;
// State for calibration capture
this.waitingForSweep = false;
this.pendingStandard = null;
this.disabledStandards = new Set(); // Track which standards are being captured
// DOM elements will be populated during init
this.elements = {};
@ -280,6 +287,32 @@ export class SettingsManager {
this.elements.calibrationStandards.innerHTML = '';
}
resetCalibrationState() {
// Clear working calibration
this.workingCalibration = null;
// Hide calibration steps UI
this.hideCalibrationSteps();
// Clear calibration name input
if (this.elements.calibrationNameInput) {
this.elements.calibrationNameInput.value = '';
this.elements.calibrationNameInput.disabled = true;
}
// Disable save button
if (this.elements.saveCalibrationBtn) {
this.elements.saveCalibrationBtn.disabled = true;
}
// Clear progress display
if (this.elements.progressText) {
this.elements.progressText.textContent = '0/0';
}
console.log('🔄 Calibration state reset for preset change');
}
generateStandardButtons(workingCalibration) {
const container = this.elements.calibrationStandards;
container.innerHTML = '';
@ -295,8 +328,15 @@ export class SettingsManager {
const isCompleted = completedStandards.includes(standard);
const isMissing = missingStandards.includes(standard);
const isBeingCaptured = this.disabledStandards.has(standard);
if (isCompleted) {
if (isBeingCaptured) {
// Standard is currently being captured
button.classList.add('btn--warning');
button.innerHTML = `<i data-lucide="clock"></i> Capturing ${standard.toUpperCase()}...`;
button.disabled = true;
button.title = 'Standard is currently being captured';
} else if (isCompleted) {
button.classList.add('btn--success');
button.innerHTML = `<i data-lucide="check"></i> ${standard.toUpperCase()}`;
button.disabled = false;
@ -364,6 +404,9 @@ export class SettingsManager {
message: result.message
});
// Reset calibration state when preset changes
this.resetCalibrationState();
// Reload status
await this.loadStatus();
@ -422,46 +465,78 @@ export class SettingsManager {
}
async handleCalibrateStandard(standard) {
// Prevent multiple simultaneous captures
if (this.disabledStandards.has(standard)) {
console.log(`Standard ${standard} is already being captured`);
return;
}
try {
// Mark this standard as disabled
this.disabledStandards.add(standard);
const button = document.querySelector(`[data-standard="${standard}"]`);
if (button) {
button.disabled = true;
button.textContent = 'Capturing...';
button.innerHTML = '<i data-lucide="clock"></i> Waiting for next sweep...';
if (typeof lucide !== 'undefined') lucide.createIcons();
}
const response = await fetch('/api/v1/settings/calibration/add-standard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ standard })
});
// Check if acquisition is running
const isAcquisitionRunning = this.acquisition && this.acquisition.isRunning();
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!isAcquisitionRunning) {
// If not running, trigger a single sweep first
this.notifications.show({
type: 'info',
title: 'Triggering Sweep',
message: `Requesting single sweep for ${standard.toUpperCase()} standard`
});
const result = await response.json();
if (this.acquisition) {
try {
await this.acquisition.triggerSingleSweep();
} catch (error) {
console.error('Failed to trigger single sweep:', error);
this.notifications.show({
type: 'error',
title: 'Sweep Error',
message: 'Failed to trigger single sweep for calibration'
});
this.resetStandardCaptureState(standard);
return;
}
}
}
// Set up state to wait for next sweep
this.waitingForSweep = true;
this.pendingStandard = standard;
// Subscribe to processor results to catch next sweep
if (this.websocket) {
this.websocket.on('processor_result', this.handleSweepForCalibration.bind(this));
}
const message = isAcquisitionRunning
? `Please trigger a new sweep to capture ${standard.toUpperCase()} standard`
: `Single sweep requested, waiting for data to capture ${standard.toUpperCase()} standard`;
this.notifications.show({
type: 'success',
title: 'Standard Captured',
message: result.message
type: 'info',
title: 'Waiting for Sweep',
message: message
});
// Reload working calibration
await this.loadWorkingCalibration();
} catch (error) {
console.error('Failed to capture standard:', error);
console.error('Failed to start standard capture:', error);
this.notifications.show({
type: 'error',
title: 'Calibration Error',
message: 'Failed to capture calibration standard'
message: 'Failed to start calibration standard capture'
});
// Re-enable button
const button = document.querySelector(`[data-standard="${standard}"]`);
if (button) {
button.disabled = false;
this.generateStandardButtons(this.workingCalibration);
}
this.resetStandardCaptureState(standard);
}
}
@ -570,7 +645,87 @@ export class SettingsManager {
await this.loadInitialData();
}
async handleSweepForCalibration(payload) {
// Only process if we're waiting for a sweep and this is sweep data
if (!this.waitingForSweep || !this.pendingStandard) return;
try {
console.log(`📡 New sweep received, capturing ${this.pendingStandard} standard...`);
// Remove listener to avoid duplicate calls
if (this.websocket) {
this.websocket.off('processor_result', this.handleSweepForCalibration.bind(this));
}
const button = document.querySelector(`[data-standard="${this.pendingStandard}"]`);
if (button) {
button.innerHTML = '<i data-lucide="upload"></i> Capturing...';
if (typeof lucide !== 'undefined') lucide.createIcons();
}
const response = await fetch('/api/v1/settings/calibration/add-standard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ standard: this.pendingStandard })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
this.notifications.show({
type: 'success',
title: 'Standard Captured',
message: result.message
});
// Reset state
this.resetStandardCaptureState();
// Reload working calibration
await this.loadWorkingCalibration();
} catch (error) {
console.error('Failed to capture standard:', error);
this.notifications.show({
type: 'error',
title: 'Calibration Error',
message: 'Failed to capture calibration standard'
});
this.resetStandardCaptureState();
}
}
resetStandardCaptureState(standard = null) {
// Reset specific standard or all
if (standard) {
this.disabledStandards.delete(standard);
} else {
this.disabledStandards.clear();
}
// If this was the pending standard, reset waiting state
if (!standard || standard === this.pendingStandard) {
this.waitingForSweep = false;
this.pendingStandard = null;
// Remove WebSocket listener if still attached
if (this.websocket) {
this.websocket.off('processor_result', this.handleSweepForCalibration.bind(this));
}
}
// Restore button states
if (this.workingCalibration) {
this.generateStandardButtons(this.workingCalibration);
}
}
destroy() {
// Clean up calibration capture state
this.resetStandardCaptureState();
// Remove event listeners
this.elements.presetDropdown?.removeEventListener('change', this.handlePresetChange);
this.elements.setPresetBtn?.removeEventListener('click', this.handleSetPreset);

View File

@ -4,8 +4,10 @@
*/
export class UIManager {
constructor(notifications) {
constructor(notifications, websocket, charts) {
this.notifications = notifications;
this.websocket = websocket; // injected WebSocketManager
this.charts = charts; // injected ChartManager
// UI Elements
this.elements = {
@ -13,7 +15,6 @@ export class UIManager {
processorToggles: null,
sweepCount: null,
dataRate: null,
clearChartsBtn: null,
exportBtn: null,
navButtons: null,
views: null
@ -22,26 +23,16 @@ export class UIManager {
// State
this.currentView = 'dashboard';
this.connectionStatus = 'disconnected';
this.processors = new Map(); // processor_name -> { enabled, count }
// processorId -> { enabled, count, uiParameters, config }
this.processors = new Map();
// Event handlers
this.eventHandlers = {
viewChange: [],
processorToggle: [],
clearCharts: [],
exportData: []
};
// Statistics tracking
this.stats = {
sweepCount: 0,
dataRate: 0,
lastUpdate: null,
rateCalculation: {
samples: [],
windowMs: 10000 // 10 second window
}
};
}
/**
@ -59,8 +50,16 @@ export class UIManager {
// Initialize UI state
this.initializeUIState();
// Start update loop
this.startUpdateLoop();
// Wire WebSocket events
if (this.websocket) {
this.websocket.on('connecting', () => this.setConnectionStatus('connecting'));
this.websocket.on('connected', () => this.setConnectionStatus('connected'));
this.websocket.on('disconnected', () => this.setConnectionStatus('disconnected'));
// main data stream from backend
this.websocket.on('processor_result', (payload) => this.onProcessorResult(payload));
}
console.log('✅ UI Manager initialized');
}
@ -71,21 +70,15 @@ export class UIManager {
findElements() {
this.elements.connectionStatus = document.getElementById('connectionStatus');
this.elements.processorToggles = document.getElementById('processorToggles');
this.elements.sweepCount = document.getElementById('sweepCount');
this.elements.dataRate = document.getElementById('dataRate');
// clearChartsBtn removed
this.elements.exportBtn = document.getElementById('exportBtn');
this.elements.navButtons = document.querySelectorAll('.nav-btn[data-view]');
this.elements.views = document.querySelectorAll('.view');
// Validate required elements
const required = [
'connectionStatus', 'processorToggles', 'exportBtn'
];
for (const element of required) {
if (!this.elements[element]) {
throw new Error(`Required UI element not found: ${element}`);
const required = ['connectionStatus', 'processorToggles', 'exportBtn'];
for (const key of required) {
if (!this.elements[key]) {
throw new Error(`Required UI element not found: ${key}`);
}
}
}
@ -102,8 +95,6 @@ export class UIManager {
});
});
// Note: Clear charts functionality removed - use processor toggles instead
// Export button
this.elements.exportBtn.addEventListener('click', () => {
this.triggerExportData();
@ -112,74 +103,37 @@ export class UIManager {
// Processor toggles container (event delegation)
this.elements.processorToggles.addEventListener('click', (e) => {
const toggle = e.target.closest('.processor-toggle');
if (toggle) {
const processor = toggle.dataset.processor;
const isEnabled = toggle.classList.contains('processor-toggle--active');
this.toggleProcessor(processor, !isEnabled);
}
if (!toggle) return;
const processor = toggle.dataset.processor;
const isEnabled = toggle.classList.contains('processor-toggle--active');
this.toggleProcessor(processor, !isEnabled);
});
// Window resize
window.addEventListener('resize', this.debounce(() => {
this.handleResize();
}, 300));
window.addEventListener('resize', this.debounce(() => this.handleResize(), 300));
}
/**
* Initialize UI state
*/
initializeUIState() {
// Set initial connection status
this.setConnectionStatus('disconnected');
// Initialize stats display
this.updateStatsDisplay();
// Set initial view
this.switchView(this.currentView);
// Clean up any invalid processors
this.cleanupInvalidProcessors();
}
/**
* Clean up invalid/old processors
*/
cleanupInvalidProcessors() {
const invalidProcessors = ['sweep_counter'];
invalidProcessors.forEach(processor => {
if (this.processors.has(processor)) {
console.log(`🧹 Removing invalid processor: ${processor}`);
this.processors.delete(processor);
}
});
}
/**
* Start update loop for real-time UI updates
*/
startUpdateLoop() {
setInterval(() => {
this.updateDataRateDisplay();
}, 1000);
}
/**
* Switch between views (Dashboard, Settings, Logs)
* Switch between views
*/
switchView(viewName) {
if (this.currentView === viewName) return;
console.log(`🔄 Switching to view: ${viewName}`);
// Update navigation buttons
this.elements.navButtons.forEach(button => {
const isActive = button.dataset.view === viewName;
button.classList.toggle('nav-btn--active', isActive);
});
// Update views
this.elements.views.forEach(view => {
const isActive = view.id === `${viewName}View`;
view.classList.toggle('view--active', isActive);
@ -195,7 +149,6 @@ export class UIManager {
setConnectionStatus(status) {
if (this.connectionStatus === status) return;
console.log(`🔌 Connection status: ${status}`);
this.connectionStatus = status;
const statusElement = this.elements.connectionStatus;
@ -227,180 +180,221 @@ export class UIManager {
}
/**
* Update processor toggles
* Handle incoming processor result from backend
*/
updateProcessorToggles(processors) {
if (!this.elements.processorToggles) return;
onProcessorResult(payload) {
const { processor_id, timestamp, data, plotly_config, ui_parameters, metadata } = payload;
if (!processor_id) return;
// Clear existing toggles
this.elements.processorToggles.innerHTML = '';
// Register/update processor in UI map
const proc = this.processors.get(processor_id) || { enabled: true };
proc.uiParameters = ui_parameters || [];
proc.config = metadata?.config || {};
this.processors.set(processor_id, proc);
// Create toggles for each processor
processors.forEach(processor => {
const toggle = this.createProcessorToggle(processor);
this.elements.processorToggles.appendChild(toggle);
});
// Refresh toggles and settings
this.refreshProcessorToggles();
this.updateProcessorSettings();
// No more statistics tracking needed
// Pass to charts
if (this.charts) {
this.charts.addResult({
processor_id,
timestamp,
data,
plotly_config,
metadata
});
}
}
/**
* Rebuild processor toggles
*/
refreshProcessorToggles() {
const container = this.elements.processorToggles;
if (!container) return;
container.innerHTML = '';
for (const [name, data] of this.processors.entries()) {
const toggle = document.createElement('div');
toggle.className = `processor-toggle ${data.enabled ? 'processor-toggle--active' : ''}`;
toggle.dataset.processor = name;
toggle.innerHTML = `
<div class="processor-toggle__checkbox"></div>
<div class="processor-toggle__label">${this.formatProcessorName(name)}</div>
`;
container.appendChild(toggle);
}
// Update Lucide icons
if (typeof lucide !== 'undefined') {
lucide.createIcons({ attrs: { 'stroke-width': 1.5 } });
}
}
/**
* Create processor toggle element
* Toggle processor visibility (UI-only)
*/
createProcessorToggle(processor) {
const toggle = document.createElement('div');
toggle.className = `processor-toggle ${processor.enabled ? 'processor-toggle--active' : ''}`;
toggle.dataset.processor = processor.name;
toggleProcessor(processorId, enabled) {
const proc = this.processors.get(processorId) || { enabled: true };
proc.enabled = enabled;
this.processors.set(processorId, proc);
toggle.innerHTML = `
<div class="processor-toggle__checkbox"></div>
<div class="processor-toggle__label">${this.formatProcessorName(processor.name)}</div>
<div class="processor-toggle__count">${processor.count || 0}</div>
`;
return toggle;
}
/**
* Toggle processor state
*/
toggleProcessor(processorName, enabled) {
console.log(`🔧 Toggle processor ${processorName}: ${enabled}`);
// Update processor state
const processor = this.processors.get(processorName) || { count: 0 };
processor.enabled = enabled;
this.processors.set(processorName, processor);
// Update UI only if elements exist
if (this.elements.processorToggles) {
const toggle = this.elements.processorToggles.querySelector(`[data-processor="${processorName}"]`);
if (toggle) {
toggle.classList.toggle('processor-toggle--active', enabled);
} else {
// Toggle element doesn't exist yet, refresh toggles
this.refreshProcessorToggles();
}
}
// Emit event
this.emitEvent('processorToggle', processorName, enabled);
}
/**
* Update processor from incoming data
*/
updateProcessorFromData(processorName) {
let processor = this.processors.get(processorName);
if (!processor) {
// New processor discovered
processor = { enabled: true, count: 0 };
this.processors.set(processorName, processor);
// Update toggle UI
const toggle = this.elements.processorToggles.querySelector(`[data-processor="${processorId}"]`);
if (toggle) {
toggle.classList.toggle('processor-toggle--active', enabled);
} else {
this.refreshProcessorToggles();
}
// Increment count
processor.count++;
// Update charts
if (this.charts) {
this.charts.toggleProcessor(processorId, enabled);
}
// Update count display
const toggle = this.elements.processorToggles.querySelector(`[data-processor="${processorName}"]`);
if (toggle) {
const countElement = toggle.querySelector('.processor-toggle__count');
if (countElement) {
countElement.textContent = processor.count;
this.emitEvent('processorToggle', processorId, enabled);
}
/**
* Update processor settings panel (deprecated - now handled by ChartManager)
*/
updateProcessorSettings() {
// Settings are now integrated with charts - no separate panel needed
return;
}
/**
* Create processor parameter element
*/
createProcessorParam(param, processorId) {
const paramId = `${processorId}_${param.name}`;
const value = param.value;
const opts = param.options || {};
switch (param.type) {
case 'slider':
case 'range':
return `
<div class="processor-param" data-param="${param.name}">
<div class="processor-param__label">
${param.label}
<span class="processor-param__value">${value}</span>
</div>
<input
type="range"
class="processor-param__slider"
id="${paramId}"
min="${opts.min ?? 0}"
max="${opts.max ?? 100}"
step="${opts.step ?? 1}"
value="${value}"
>
</div>
`;
case 'toggle':
case 'boolean':
return `
<div class="processor-param" data-param="${param.name}">
<div class="processor-param__label">${param.label}</div>
<label class="processor-param__toggle">
<input type="checkbox" id="${paramId}" ${value ? 'checked' : ''}>
<span class="processor-param__toggle-slider"></span>
</label>
</div>
`;
case 'select':
case 'dropdown': {
let choices = [];
if (Array.isArray(opts)) choices = opts;
else if (Array.isArray(opts.choices)) choices = opts.choices;
const optionsHtml = choices.map(option =>
`<option value="${option}" ${option === value ? 'selected' : ''}>${option}</option>`
).join('');
return `
<div class="processor-param" data-param="${param.name}">
<div class="processor-param__label">${param.label}</div>
<select class="processor-param__select" id="${paramId}">
${optionsHtml}
</select>
</div>
`;
}
default:
return `
<div class="processor-param" data-param="${param.name}">
<div class="processor-param__label">${param.label}</div>
<input
type="text"
class="form-input"
id="${paramId}"
value="${value ?? ''}"
>
</div>
`;
}
}
/**
* Refresh processor toggles display
* Toggle processor config panel
*/
refreshProcessorToggles() {
const processors = Array.from(this.processors.entries()).map(([name, data]) => ({
name,
enabled: data.enabled,
count: data.count
}));
this.updateProcessorToggles(processors);
toggleProcessorConfig(configElement) {
if (!configElement) return;
const isExpanded = configElement.classList.contains('processor-config--expanded');
configElement.classList.toggle('processor-config--expanded', !isExpanded);
}
/**
* Update statistics display
* Handle processor parameter change
* Sends 'recalculate' with config_updates
*/
updateStats(newStats) {
if (newStats.sweepCount !== undefined) {
this.stats.sweepCount = newStats.sweepCount;
handleProcessorParamChange(event) {
const paramElement = event.target.closest('.processor-param');
const configElement = event.target.closest('.processor-config');
if (!paramElement || !configElement) return;
const processorId = configElement.dataset.processor;
const paramName = paramElement.dataset.param;
const input = event.target;
let value;
if (input.type === 'checkbox') {
value = input.checked;
} else if (input.type === 'range') {
value = parseFloat(input.value);
// Update display value
const valueDisplay = paramElement.querySelector('.processor-param__value');
if (valueDisplay) valueDisplay.textContent = value;
} else {
value = input.value;
}
if (newStats.lastUpdate) {
this.updateDataRate(newStats.lastUpdate);
}
console.log(`🔧 Parameter changed: ${processorId}.${paramName} = ${value}`);
this.updateStatsDisplay();
}
/**
* Update data rate calculation
*/
updateDataRate(timestamp) {
const now = timestamp.getTime();
this.stats.rateCalculation.samples.push(now);
// Remove samples outside the window
const cutoff = now - this.stats.rateCalculation.windowMs;
this.stats.rateCalculation.samples = this.stats.rateCalculation.samples
.filter(time => time > cutoff);
// Calculate rate (samples per second)
const samplesInWindow = this.stats.rateCalculation.samples.length;
const windowSeconds = this.stats.rateCalculation.windowMs / 1000;
this.stats.dataRate = Math.round(samplesInWindow / windowSeconds * 10) / 10;
}
/**
* Update statistics display elements
*/
updateStatsDisplay() {
if (this.elements.sweepCount) {
this.elements.sweepCount.textContent = this.stats.sweepCount.toLocaleString();
}
this.updateDataRateDisplay();
}
/**
* Update data rate display
*/
updateDataRateDisplay() {
if (this.elements.dataRate) {
this.elements.dataRate.textContent = `${this.stats.dataRate}/s`;
// Send update via WebSocket using existing command
if (this.websocket) {
this.websocket.recalculate(processorId, { [paramName]: value });
}
}
/**
* Set processor enabled state (external API)
*/
setProcessorEnabled(processorName, enabled) {
if (!processorName) return;
// If processor doesn't exist yet, store the preference for later
if (!this.processors.has(processorName)) {
const processor = { enabled, count: 0 };
this.processors.set(processorName, processor);
/**
* Public API
*/
setProcessorEnabled(processorId, enabled) {
if (!processorId) return;
if (!this.processors.has(processorId)) {
const processor = { enabled };
this.processors.set(processorId, processor);
return;
}
this.toggleProcessor(processorName, enabled);
this.toggleProcessor(processorId, enabled);
}
/**
* Format processor name for display
*/
formatProcessorName(processorName) {
return processorName
.split('_')
@ -408,84 +402,34 @@ export class UIManager {
.join(' ');
}
// Clear charts functionality removed - use processor toggles
/**
* Trigger export data action
*/
triggerExportData() {
this.emitEvent('exportData');
}
/**
* Handle window resize
*/
handleResize() {
console.log('📱 Window resized');
// Trigger chart resize if needed
// This is handled by the chart manager
// Charts handle their own resize
}
/**
* Update system status (for future use)
*/
updateSystemStatus(statusData) {
console.log('📊 System status update:', statusData);
// Future implementation for system health monitoring
}
// System status and theme methods simplified
updateSystemStatus(statusData) { console.log('📊 System status:', statusData); }
setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); }
getCurrentTheme() { return document.documentElement.getAttribute('data-theme') || 'dark'; }
/**
* Set theme (for future use)
*/
setTheme(theme) {
console.log(`🎨 Setting theme: ${theme}`);
document.documentElement.setAttribute('data-theme', theme);
}
// Events
onViewChange(callback) { this.eventHandlers.viewChange.push(callback); }
onProcessorToggle(callback) { this.eventHandlers.processorToggle.push(callback); }
onExportData(callback) { this.eventHandlers.exportData.push(callback); }
/**
* Get current theme
*/
getCurrentTheme() {
return document.documentElement.getAttribute('data-theme') || 'dark';
}
/**
* Event system
*/
onViewChange(callback) {
this.eventHandlers.viewChange.push(callback);
}
onProcessorToggle(callback) {
this.eventHandlers.processorToggle.push(callback);
}
onClearCharts(callback) {
this.eventHandlers.clearCharts.push(callback);
}
onExportData(callback) {
this.eventHandlers.exportData.push(callback);
}
/**
* Emit event to registered handlers
*/
emitEvent(eventType, ...args) {
if (this.eventHandlers[eventType]) {
this.eventHandlers[eventType].forEach(handler => {
try {
handler(...args);
} catch (error) {
console.error(`❌ Error in ${eventType} handler:`, error);
}
try { handler(...args); }
catch (error) { console.error(`❌ Error in ${eventType} handler:`, error); }
});
}
}
/**
* Utility: Debounce function
*/
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
@ -498,21 +442,14 @@ export class UIManager {
};
}
/**
* Get UI statistics
*/
getStats() {
return {
currentView: this.currentView,
connectionStatus: this.connectionStatus,
processorsCount: this.processors.size,
...this.stats
processorsCount: this.processors.size
};
}
/**
* Cleanup
*/
destroy() {
console.log('🧹 Cleaning up UI Manager...');
@ -526,4 +463,4 @@ export class UIManager {
console.log('✅ UI Manager cleanup complete');
}
}
}

View File

@ -16,11 +16,9 @@ export class WebSocketManager {
this.lastPing = null;
this.eventListeners = new Map();
// Message queue for when disconnected
this.messageQueue = [];
this.maxQueueSize = 100;
// Statistics
this.stats = {
messagesReceived: 0,
messagesPerSecond: 0,
@ -29,14 +27,10 @@ export class WebSocketManager {
bytesReceived: 0
};
// Rate limiting for message processing
this.messageRateCounter = [];
this.rateWindowMs = 1000;
}
/**
* Connect to WebSocket server
*/
async connect() {
if (this.isConnected || this.isConnecting) {
console.log('🔌 WebSocket already connected/connecting');
@ -52,9 +46,7 @@ export class WebSocketManager {
this.ws = new WebSocket(this.config.url);
this.setupWebSocketEvents();
// Wait for connection or timeout
await this.waitForConnection(5000);
} catch (error) {
this.isConnecting = false;
console.error('❌ WebSocket connection failed:', error);
@ -63,52 +55,32 @@ export class WebSocketManager {
}
}
/**
* Wait for WebSocket connection with timeout
*/
waitForConnection(timeoutMs) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('WebSocket connection timeout'));
}, timeoutMs);
const timeout = setTimeout(() => reject(new Error('WebSocket connection timeout')), timeoutMs);
const checkConnection = () => {
const check = () => {
if (this.isConnected) {
clearTimeout(timeout);
resolve();
} else if (this.ws?.readyState === WebSocket.CLOSED ||
this.ws?.readyState === WebSocket.CLOSING) {
} else if (this.ws?.readyState === WebSocket.CLOSED || this.ws?.readyState === WebSocket.CLOSING) {
clearTimeout(timeout);
reject(new Error('WebSocket connection failed'));
}
};
// Check immediately and then every 100ms
checkConnection();
check();
const interval = setInterval(() => {
checkConnection();
if (this.isConnected) {
clearInterval(interval);
}
check();
if (this.isConnected) clearInterval(interval);
}, 100);
// Clean up interval on resolve/reject
const originalResolve = resolve;
const originalReject = reject;
resolve = (...args) => {
clearInterval(interval);
originalResolve(...args);
};
reject = (...args) => {
clearInterval(interval);
originalReject(...args);
};
const wrap = fn => (...args) => { clearInterval(interval); fn(...args); };
resolve = wrap(resolve);
reject = wrap(reject);
});
}
/**
* Set up WebSocket event handlers
*/
setupWebSocketEvents() {
if (!this.ws) return;
@ -123,7 +95,7 @@ export class WebSocketManager {
this.processPendingMessages();
this.emit('connected', event);
this.notifications.show({
this.notifications?.show?.({
type: 'success',
title: 'Connected',
message: 'Real-time connection established'
@ -135,7 +107,7 @@ export class WebSocketManager {
this.handleMessage(event.data);
} catch (error) {
console.error('❌ Error processing WebSocket message:', error);
this.notifications.show({
this.notifications?.show?.({
type: 'error',
title: 'Message Error',
message: 'Failed to process received data'
@ -154,102 +126,88 @@ export class WebSocketManager {
};
}
/**
* Handle incoming messages
*/
handleMessage(data) {
// Update statistics
this.stats.messagesReceived++;
this.stats.lastMessageTime = new Date();
this.stats.bytesReceived += data.length;
// Rate limiting check
this.stats.bytesReceived += (typeof data === 'string' ? data.length : 0);
this.updateMessageRate();
try {
let parsedData;
if (typeof data !== 'string') {
console.warn('⚠️ Received non-text message, ignoring');
return;
}
if (data === 'ping') { this.handlePing(); return; }
if (data === 'pong') { this.handlePong(); return; }
// Handle different message types
if (typeof data === 'string') {
if (data === 'ping') {
this.handlePing();
return;
} else if (data === 'pong') {
this.handlePong();
return;
} else {
parsedData = JSON.parse(data);
}
} else {
// Handle binary data if needed
console.warn('⚠️ Received binary data, not implemented');
let payload;
try {
payload = JSON.parse(data);
} catch (jsonError) {
console.error('❌ Invalid JSON format:', data);
console.error('JSON parse error:', jsonError);
this.notifications?.show?.({
type: 'error',
title: 'WebSocket Error',
message: 'Received invalid JSON from server'
});
return;
}
// Emit data event
this.emit('data', parsedData);
// Публичное событие «data» — для всего, плюс более точечные:
this.emit('data', payload);
} catch (error) {
console.error('❌ Failed to parse WebSocket message:', error);
switch (payload.type) {
case 'processor_result':
this.emit('processor_result', payload);
break;
case 'processor_history':
this.emit('processor_history', payload);
break;
case 'error':
console.error('🔴 Server error:', payload.message);
this.notifications?.show?.({
type: 'error', title: 'Server Error', message: payload.message
});
break;
default:
console.warn('⚠️ Unknown payload type:', payload.type);
}
} catch (e) {
console.error('❌ Failed to parse WebSocket JSON:', e);
console.log('📝 Raw message:', data);
}
}
/**
* Update message rate statistics
*/
updateMessageRate() {
const now = Date.now();
this.messageRateCounter.push(now);
// Remove messages outside the rate window
this.messageRateCounter = this.messageRateCounter.filter(
time => now - time < this.rateWindowMs
);
this.messageRateCounter = this.messageRateCounter.filter(t => now - t < this.rateWindowMs);
this.stats.messagesPerSecond = this.messageRateCounter.length;
}
/**
* Handle ping message
*/
handlePing() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send('pong');
}
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send('pong');
}
/**
* Handle pong message
*/
handlePong() {
if (this.lastPing) {
const latency = Date.now() - this.lastPing;
console.log(`🏓 WebSocket latency: ${latency}ms`);
console.log(`🏓 WebSocket latency: ${Date.now() - this.lastPing}ms`);
this.lastPing = null;
}
}
/**
* Start ping-pong mechanism
*/
startPingPong() {
this.pingTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.lastPing = Date.now();
this.ws.send('ping');
}
}, 30000); // Ping every 30 seconds
}, 30000);
}
/**
* Handle connection errors
*/
handleConnectionError(error) {
console.error('❌ WebSocket connection error:', error);
if (!this.isConnected) {
this.notifications.show({
handleConnectionError() {
if (!this.isConnected && this.isConnecting) {
// Only show error notification during connection attempts, not after disconnection
this.notifications?.show?.({
type: 'error',
title: 'Connection Failed',
message: 'Unable to establish real-time connection'
@ -257,89 +215,53 @@ export class WebSocketManager {
}
}
/**
* Handle disconnection
*/
handleDisconnection(event) {
const wasConnected = this.isConnected;
this.isConnected = false;
this.isConnecting = false;
// Clean up timers
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; }
this.emit('disconnected', event);
if (wasConnected) {
this.notifications.show({
this.notifications?.show?.({
type: 'warning',
title: 'Disconnected',
message: 'Real-time connection lost. Attempting to reconnect...'
});
// Auto-reconnect
this.scheduleReconnect();
}
}
/**
* Schedule automatic reconnection
*/
scheduleReconnect() {
if (this.reconnectTimer) return;
const delay = Math.min(
this.config.reconnectInterval * Math.pow(2, this.reconnectAttempts),
30000 // Max 30 seconds
);
const delay = Math.min(this.config.reconnectInterval * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`🔄 Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts + 1})`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.reconnect();
}, delay);
}
/**
* Manually trigger reconnection
*/
async reconnect() {
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
console.error('❌ Max reconnection attempts reached');
this.notifications.show({
this.notifications?.show?.({
type: 'error',
title: 'Connection Failed',
message: 'Unable to reconnect after multiple attempts'
});
return;
}
this.reconnectAttempts++;
// Close existing connection
if (this.ws) {
this.ws.close();
this.ws = null;
}
try {
await this.connect();
} catch (error) {
console.error(`❌ Reconnection attempt ${this.reconnectAttempts} failed:`, error);
this.scheduleReconnect();
}
if (this.ws) { this.ws.close(); this.ws = null; }
try { await this.connect(); }
catch (error) { console.error(`❌ Reconnection attempt ${this.reconnectAttempts} failed:`, error); this.scheduleReconnect(); }
}
/**
* Send message
*/
send(data) {
if (!this.isConnected || !this.ws) {
// Queue message for later
if (this.messageQueue.length < this.maxQueueSize) {
this.messageQueue.push(data);
console.log('📤 Message queued (not connected)');
@ -348,65 +270,72 @@ export class WebSocketManager {
}
return false;
}
try {
const message = typeof data === 'string' ? data : JSON.stringify(data);
this.ws.send(message);
const msg = (typeof data === 'string') ? data : JSON.stringify(data);
this.ws.send(msg);
return true;
} catch (error) {
console.error('❌ Failed to send message:', error);
} catch (e) {
console.error('❌ Failed to send message:', e);
return false;
}
}
/**
* Process pending messages after reconnection
*/
processPendingMessages() {
if (this.messageQueue.length > 0) {
console.log(`📤 Processing ${this.messageQueue.length} queued messages`);
const messages = [...this.messageQueue];
this.messageQueue = [];
messages.forEach(message => {
this.send(message);
});
}
if (this.messageQueue.length === 0) return;
console.log(`📤 Processing ${this.messageQueue.length} queued messages`);
const messages = [...this.messageQueue];
this.messageQueue = [];
messages.forEach(m => this.send(m));
}
// === ПУБЛИЧНОЕ API ДЛЯ СОВМЕСТИМОСТИ С БЭКЕНДОМ ===
/** Запросить пересчёт с обновлением конфигурации (СУЩЕСТВУЕТ НА БЭКЕНДЕ) */
recalculate(processorId, configUpdates = undefined) {
return this.send({
type: 'recalculate',
processor_id: processorId,
...(configUpdates ? { config_updates: configUpdates } : {})
});
}
/** Получить историю результатов процессора (СУЩЕСТВУЕТ НА БЭКЕНДЕ) */
getHistory(processorId, limit = 10) {
return this.send({
type: 'get_history',
processor_id: processorId,
limit
});
}
// === Events ===
on(event, callback) {
if (!this.eventListeners.has(event)) this.eventListeners.set(event, []);
this.eventListeners.get(event).push(callback);
}
off(event, callback) {
if (!this.eventListeners.has(event)) return;
const arr = this.eventListeners.get(event);
const i = arr.indexOf(callback);
if (i > -1) arr.splice(i, 1);
}
emit(event, data) {
if (!this.eventListeners.has(event)) return;
this.eventListeners.get(event).forEach(cb => {
try { cb(data); } catch (e) { console.error(`❌ Error in event listener for ${event}:`, e); }
});
}
/**
* Disconnect WebSocket
*/
disconnect() {
console.log('🔌 Disconnecting WebSocket');
// Clear reconnection timer
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
// Clear ping timer
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
// Close WebSocket
if (this.ws) {
this.ws.close(1000, 'Manual disconnect');
this.ws = null;
}
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; }
if (this.ws) { this.ws.close(1000, 'Manual disconnect'); this.ws = null; }
this.isConnected = false;
this.isConnecting = false;
this.reconnectAttempts = 0;
}
/**
* Get connection statistics
*/
getStats() {
return {
...this.stats,
@ -417,44 +346,9 @@ export class WebSocketManager {
};
}
/**
* Event listener management
*/
on(event, callback) {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, []);
}
this.eventListeners.get(event).push(callback);
}
off(event, callback) {
if (this.eventListeners.has(event)) {
const listeners = this.eventListeners.get(event);
const index = listeners.indexOf(callback);
if (index > -1) {
listeners.splice(index, 1);
}
}
}
emit(event, data) {
if (this.eventListeners.has(event)) {
this.eventListeners.get(event).forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`❌ Error in event listener for ${event}:`, error);
}
});
}
}
/**
* Cleanup
*/
destroy() {
this.disconnect();
this.eventListeners.clear();
this.messageQueue = [];
}
}
}

View File

@ -32,6 +32,7 @@
<link rel="stylesheet" href="/static/css/components.css">
<link rel="stylesheet" href="/static/css/charts.css">
<link rel="stylesheet" href="/static/css/settings.css">
<link rel="stylesheet" href="/static/css/acquisition.css">
</head>
<body>
<!-- Header -->
@ -49,16 +50,6 @@
<span class="status-indicator__text">Connecting...</span>
</div>
<div class="header__stats">
<div class="stat-item">
<span class="stat-label">Sweeps:</span>
<span class="stat-value" id="sweepCount">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Rate:</span>
<span class="stat-value" id="dataRate">0/s</span>
</div>
</div>
</div>
<nav class="header__nav">
@ -81,6 +72,34 @@
<!-- Controls Panel -->
<div class="controls-panel">
<div class="controls-panel__container">
<div class="controls-group">
<label class="controls-label">Acquisition Control</label>
<div class="acquisition-controls">
<button class="btn btn--primary btn--bordered" id="startBtn" title="Start continuous acquisition">
<i data-lucide="play"></i>
Start
</button>
<button class="btn btn--secondary btn--bordered" id="stopBtn" title="Stop acquisition">
<i data-lucide="square"></i>
Stop
</button>
<button class="btn btn--accent btn--bordered" id="singleSweepBtn" title="Trigger single sweep">
<i data-lucide="zap"></i>
Single
</button>
</div>
<div class="acquisition-status" id="acquisitionStatus">
<div class="status-indicator">
<div class="status-indicator__dot status-indicator__dot--idle"></div>
<span class="status-indicator__text" id="acquisitionStatusText">Idle</span>
</div>
<div class="acquisition-mode" id="acquisitionMode">
<span class="mode-label">Mode:</span>
<span class="mode-value" id="acquisitionModeText">Continuous</span>
</div>
</div>
</div>
<div class="controls-group">
<label class="controls-label">Processors</label>
<div class="processor-toggles" id="processorToggles">