added processors and fixed errors
This commit is contained in:
88
vna_system/api/endpoints/acquisition.py
Normal file
88
vna_system/api/endpoints/acquisition.py
Normal 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))
|
||||
@ -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,
|
||||
}
|
||||
@ -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]
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
@ -1 +1 @@
|
||||
config_inputs/s11_start100_stop8800_points1000_bw1khz.bin
|
||||
config_inputs/s21_start100_stop8800_points1000_bw1khz.bin
|
||||
@ -1 +0,0 @@
|
||||
s11_start100_stop8800_points1000_bw1khz/tuncTuncTuncSahur
|
||||
@ -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
@ -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
@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
@ -1 +0,0 @@
|
||||
"""Sweep data processing module."""
|
||||
@ -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,
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
"""Sweep data processors."""
|
||||
@ -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)),
|
||||
}
|
||||
@ -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())
|
||||
@ -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,
|
||||
}
|
||||
@ -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)
|
||||
0
vna_system/core/processors/__init__.py
Normal file
0
vna_system/core/processors/__init__.py
Normal file
190
vna_system/core/processors/base_processor.py
Normal file
190
vna_system/core/processors/base_processor.py
Normal 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
|
||||
}
|
||||
@ -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
|
||||
9
vna_system/core/processors/configs/magnitude_config.json
Normal file
9
vna_system/core/processors/configs/magnitude_config.json
Normal 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
|
||||
}
|
||||
13
vna_system/core/processors/configs/phase_config.json
Normal file
13
vna_system/core/processors/configs/phase_config.json
Normal 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
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
5
vna_system/core/processors/implementations/__init__.py
Normal file
5
vna_system/core/processors/implementations/__init__.py
Normal 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']
|
||||
@ -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
|
||||
242
vna_system/core/processors/implementations/phase_processor.py
Normal file
242
vna_system/core/processors/implementations/phase_processor.py
Normal 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
|
||||
@ -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'")
|
||||
189
vna_system/core/processors/manager.py
Normal file
189
vna_system/core/processors/manager.py
Normal 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
|
||||
3
vna_system/core/processors/storage/__init__.py
Normal file
3
vna_system/core/processors/storage/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .data_storage import DataStorage, StorageEntry
|
||||
|
||||
__all__ = ['DataStorage', 'StorageEntry']
|
||||
162
vna_system/core/processors/storage/data_storage.py
Normal file
162
vna_system/core/processors/storage/data_storage.py
Normal 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
|
||||
224
vna_system/core/processors/websocket_handler.py
Normal file
224
vna_system/core/processors/websocket_handler.py
Normal 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}")
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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"""
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
81
vna_system/web_ui/static/css/acquisition.css
Normal file
81
vna_system/web_ui/static/css/acquisition.css
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
255
vna_system/web_ui/static/js/modules/acquisition.js
Normal file
255
vna_system/web_ui/static/js/modules/acquisition.js
Normal 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
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
Reference in New Issue
Block a user