diff --git a/reference_data_acquisition/bridge_logger.py b/reference_data_acquisition/bridge_logger.py
new file mode 100755
index 0000000..5370848
--- /dev/null
+++ b/reference_data_acquisition/bridge_logger.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+"""
+Half-duplex serial bridge & logger.
+
+- Open two fds:
+ host_fd: readable/writable endpoint the app uses (VIRT_DEV)
+ dev_fd: real serial device (REAL_DEV)
+- Relay bytes between them.
+- Build one log record per *directional burst*:
+ - When bytes arrive from host: start/continue a TX frame.
+ - When first byte arrives from device: flush TX (if any), start RX frame.
+ - And vice versa.
+- Binary log format:
+ MAGIC "VNALOG1\n"
+ Then repeated records: [1 byte dir][4 bytes len (big-endian)][payload]
+ dir 0x01 = host→device ('>'), 0x00 = device→host ('<')
+"""
+
+import os, sys, fcntl, select, struct
+
+MAGIC = b"VNALOG1\n"
+DIR_TX = 0x01 # host→device
+DIR_RX = 0x00 # device→host
+
+if len(sys.argv) != 4:
+ print("usage: bridge_logger.py VIRT_DEV REAL_DEV LOGFILE", file=sys.stderr)
+ sys.exit(2)
+VIRT_DEV, REAL_DEV, LOGFILE = sys.argv[1:4]
+
+# Open endpoints non-blocking, read+write
+host_fd = os.open(VIRT_DEV, os.O_RDWR | os.O_NONBLOCK)
+dev_fd = os.open(REAL_DEV, os.O_RDWR | os.O_NONBLOCK)
+
+# Prepare log (write header if empty)
+log_fd = os.open(LOGFILE, os.O_CREAT | os.O_RDWR, 0o644)
+if os.lseek(log_fd, 0, os.SEEK_END) == 0:
+ os.write(log_fd, MAGIC)
+
+def set_nonblock(fd):
+ fl = fcntl.fcntl(fd, fcntl.F_GETFL)
+ if not (fl & os.O_NONBLOCK):
+ fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
+set_nonblock(host_fd); set_nonblock(dev_fd)
+
+poll = select.poll()
+poll.register(host_fd, select.POLLIN)
+poll.register(dev_fd, select.POLLIN)
+
+# Current frame state
+cur_dir = None # None / DIR_TX / DIR_RX
+buf = bytearray()
+
+READ_CHUNK = 65536
+
+def write_all(fd, data: bytes):
+ """Non-blocking write-all with POLLOUT assistance."""
+ view = memoryview(data)
+ off = 0
+ while off < len(view):
+ try:
+ n = os.write(fd, view[off:])
+ if n is None: n = 0
+ except BlockingIOError:
+ # Wait for fd to become writable
+ p = select.poll(); p.register(fd, select.POLLOUT)
+ p.poll(1000)
+ continue
+ off += n
+
+def flush_record():
+ """If a frame is open, append it as one binary record and reset buffer."""
+ global cur_dir, buf
+ if cur_dir is None or not buf:
+ return
+ # dir byte + 4-byte big-endian length + payload
+ rec = bytes([cur_dir]) + struct.pack(">I", len(buf)) + bytes(buf)
+ # Single write is fine (kernel will split if huge)
+ os.write(log_fd, rec)
+ buf.clear()
+ cur_dir = None
+
+try:
+ while True:
+ events = poll.poll(1000) # 1s tick; framing doesn't depend on idle
+ if not events:
+ # If nothing is happening for a while, we could flush a tail,
+ # but with half-duplex the tail will be flushed on direction switch or exit.
+ continue
+ for (fd, ev) in events:
+ if not (ev & select.POLLIN):
+ continue
+ try:
+ chunk = os.read(fd, READ_CHUNK)
+ except BlockingIOError:
+ continue
+ if not chunk:
+ # EOF on one side: flush and exit
+ flush_record()
+ sys.exit(0)
+
+ if fd is host_fd:
+ # Incoming from host: forward to device
+ if cur_dir is None:
+ cur_dir = DIR_TX
+ elif cur_dir != DIR_TX:
+ # Direction switched: flush previous RX frame, start TX
+ flush_record()
+ cur_dir = DIR_TX
+ buf.extend(chunk)
+ write_all(dev_fd, chunk)
+ else:
+ # Incoming from device: forward to host
+ if cur_dir is None:
+ cur_dir = DIR_RX
+ elif cur_dir != DIR_RX:
+ # Direction switched: flush previous TX frame, start RX
+ flush_record()
+ cur_dir = DIR_RX
+ buf.extend(chunk)
+ write_all(host_fd, chunk)
+except KeyboardInterrupt:
+ pass
+finally:
+ # Final flush and cleanup
+ flush_record()
+ try: os.close(host_fd)
+ except: pass
+ try: os.close(dev_fd)
+ except: pass
+ try: os.close(log_fd)
+ except: pass
\ No newline at end of file
diff --git a/reference_data_acquisition/socat_serial_sniffer.sh b/reference_data_acquisition/socat_serial_sniffer.sh
new file mode 100755
index 0000000..38b1ec7
--- /dev/null
+++ b/reference_data_acquisition/socat_serial_sniffer.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+IFS=$'\n\t'
+
+# --- Settings ----------------------------------------------------------------
+LOGFILE="vna_protocol.bin" # will be normalized to absolute path
+DEVICE="/dev/ttyACM0"
+REAL_DEV="${DEVICE}.real"
+VIRT_DEV="/tmp/vna_log" # PTY endpoint for the app (peer created by socat)
+
+# Normalize LOGFILE and ensure directory exists
+if command -v realpath >/dev/null 2>&1; then
+ LOGFILE="$(realpath -m -- "$LOGFILE")"
+else
+ LOGFILE="$(cd "$(dirname "$LOGFILE")" && pwd -P)/$(basename "$LOGFILE")"
+fi
+mkdir -p "$(dirname "$LOGFILE")"
+
+BRIDGE="./bridge_logger.py"
+
+# --- Helpers -----------------------------------------------------------------
+restore_device() {
+ [[ -e "$VIRT_DEV" || -L "$VIRT_DEV" ]] && rm -f "$VIRT_DEV" || true
+ if [[ -e "$DEVICE" && ! -c "$DEVICE" ]]; then rm -f "$DEVICE" || true; fi
+ if [[ -e "$REAL_DEV" ]]; then sudo mv -f "$REAL_DEV" "$DEVICE" || true; fi
+ if [[ -e "$REAL_DEV" && -c "$DEVICE" ]]; then rm -f "$REAL_DEV" || true; fi
+}
+
+kill_descendants() {
+ pkill -TERM -P $$ 2>/dev/null || true
+ sleep 0.1
+ pkill -KILL -P $$ 2>/dev/null || true
+}
+
+
+# --- Cleanup -----------------------------------------------------------------
+CLEANED=0
+cleanup() {
+ (( CLEANED )) && return 0
+ CLEANED=1
+ set +e
+ echo; echo "Stopping logger..."
+ restore_device
+ kill_descendants
+ echo "Log saved to: $LOGFILE"
+ echo "Cleanup complete."
+}
+trap cleanup EXIT INT TERM HUP QUIT
+
+echo "== Preparing direction-switch protocol logging =="
+echo "Binary log file: $LOGFILE"
+
+# Fix leftovers from previous run (if any)
+if [[ -e "$REAL_DEV" ]]; then
+ echo "Found leftover $REAL_DEV — restoring..."
+ restore_device
+fi
+
+# Safety checks
+[[ -e "$DEVICE" ]] || { echo "Error: device $DEVICE not found."; exit 1; }
+[[ -c "$DEVICE" ]] || { echo "Error: $DEVICE is not a character device."; exit 1; }
+
+# Park the real device and create a PTY pair for the application
+sudo mv "$DEVICE" "$REAL_DEV"
+socat -d -d pty,raw,echo=0,link="$DEVICE",mode=666 \
+ pty,raw,echo=0,link="$VIRT_DEV" &
+sleep 1
+
+# Configure the real serial device (adjust if needed)
+stty -F "$REAL_DEV" 115200 cs8 -cstopb -parenb -ixon -ixoff -crtscts raw -echo
+
+# Initialize the binary log with a magic header (if empty)
+if [[ ! -s "$LOGFILE" ]]; then
+ printf 'VNALOG1\n' > "$LOGFILE"
+fi
+
+# Run the bridge+logger (single process handles both directions + logging)
+"$BRIDGE" "$VIRT_DEV" "$REAL_DEV" "$LOGFILE" &
+
+echo "Logging active. Press Ctrl+C to stop."
+wait
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..b88a936
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+pyserial==3.5
+websockets==15.0.1
+# ??? kaleido==1.1.0
\ No newline at end of file
diff --git a/vna_system/api/__init__.py b/vna_system/api/__init__.py
new file mode 100644
index 0000000..48f5e3a
--- /dev/null
+++ b/vna_system/api/__init__.py
@@ -0,0 +1 @@
+"""VNA System API module."""
\ No newline at end of file
diff --git a/vna_system/api/api_config.json b/vna_system/api/api_config.json
new file mode 100644
index 0000000..08206f4
--- /dev/null
+++ b/vna_system/api/api_config.json
@@ -0,0 +1,9 @@
+{
+ "server": {
+ "host": "0.0.0.0",
+ "port": 8000
+ },
+ "logging": {
+ "level": "INFO"
+ }
+}
\ No newline at end of file
diff --git a/vna_system/api/endpoints/__init__.py b/vna_system/api/endpoints/__init__.py
new file mode 100644
index 0000000..d458bbe
--- /dev/null
+++ b/vna_system/api/endpoints/__init__.py
@@ -0,0 +1 @@
+"""API endpoints module."""
\ No newline at end of file
diff --git a/vna_system/api/endpoints/health.py b/vna_system/api/endpoints/health.py
new file mode 100644
index 0000000..f7fc5b3
--- /dev/null
+++ b/vna_system/api/endpoints/health.py
@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+"""
+Health and status endpoints.
+"""
+
+from fastapi import APIRouter
+
+import vna_system.core.singletons as singletons
+
+
+router = APIRouter(prefix="/api/v1", tags=["health"])
+
+
+@router.get("/")
+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
+ device_info = acquisition.get_device_info()
+
+ return {
+ "device_info": device_info,
+ "acquisition_running": acquisition.is_running,
+ "last_sweep": acquisition.sweep_buffer.get_latest_sweep_number(),
+ "total_sweeps": len(acquisition.sweep_buffer.get_all_sweeps()),
+ "buffer_stats": acquisition.sweep_buffer.get_stats(),
+ }
\ No newline at end of file
diff --git a/vna_system/api/endpoints/processing.py b/vna_system/api/endpoints/processing.py
new file mode 100644
index 0000000..7dc08ee
--- /dev/null
+++ b/vna_system/api/endpoints/processing.py
@@ -0,0 +1,27 @@
+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]
\ No newline at end of file
diff --git a/vna_system/api/endpoints/web_ui.py b/vna_system/api/endpoints/web_ui.py
new file mode 100644
index 0000000..de7d931
--- /dev/null
+++ b/vna_system/api/endpoints/web_ui.py
@@ -0,0 +1,57 @@
+"""
+Web UI endpoints for serving the dashboard
+"""
+
+from fastapi import APIRouter
+from fastapi.responses import HTMLResponse
+from pathlib import Path
+import logging
+
+logger = logging.getLogger(__name__)
+
+# Create router
+router = APIRouter(prefix="", tags=["web-ui"])
+
+# Get the web UI directory path
+WEB_UI_DIR = Path(__file__).parent.parent.parent / "web_ui"
+STATIC_DIR = WEB_UI_DIR / "static"
+TEMPLATES_DIR = WEB_UI_DIR / "templates"
+
+# Static files will be mounted in main.py
+
+@router.get("/", response_class=HTMLResponse)
+async def dashboard():
+ """Serve the main dashboard page."""
+ try:
+ index_file = TEMPLATES_DIR / "index.html"
+
+ if not index_file.exists():
+ logger.error(f"Dashboard template not found: {index_file}")
+ return HTMLResponse(
+ content="
Dashboard Not Available
The web UI template could not be found.
",
+ status_code=404
+ )
+
+ with open(index_file, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ return HTMLResponse(content=content)
+
+ except Exception as e:
+ logger.error(f"Error serving dashboard: {e}")
+ return HTMLResponse(
+ content=f"Error
Unable to load dashboard: {e}
",
+ status_code=500
+ )
+
+@router.get("/health-ui")
+async def health_ui():
+ """Health check endpoint for web UI."""
+ return {
+ "service": "VNA Web UI",
+ "status": "healthy",
+ "web_ui_dir": str(WEB_UI_DIR),
+ "static_dir_exists": STATIC_DIR.exists(),
+ "templates_dir_exists": TEMPLATES_DIR.exists(),
+ "index_exists": (TEMPLATES_DIR / "index.html").exists()
+ }
\ No newline at end of file
diff --git a/vna_system/api/main.py b/vna_system/api/main.py
new file mode 100644
index 0000000..914f0e7
--- /dev/null
+++ b/vna_system/api/main.py
@@ -0,0 +1,143 @@
+from __future__ import annotations
+
+import json
+import logging
+import sys
+from contextlib import asynccontextmanager
+from typing import Any, Dict
+
+import uvicorn
+from fastapi import FastAPI
+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.api.websockets.websocket_handler import WebSocketManager
+from vna_system.api.endpoints import health, processing, web_ui
+from vna_system.api.websockets import processing as ws_processing
+
+
+# Configure logging
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+)
+
+# Disable noisy third-party loggers
+logging.getLogger('kaleido').setLevel(logging.ERROR)
+logging.getLogger('choreographer').setLevel(logging.ERROR)
+logging.getLogger('kaleido.kaleido').setLevel(logging.ERROR)
+logging.getLogger('choreographer.browsers.chromium').setLevel(logging.ERROR)
+logging.getLogger('choreographer.browser_async').setLevel(logging.ERROR)
+logging.getLogger('choreographer.utils._tmpfile').setLevel(logging.ERROR)
+logging.getLogger('kaleido._kaleido_tab').setLevel(logging.ERROR)
+
+logger = logging.getLogger(__name__)
+
+
+
+
+def load_config(config_path: str = "vna_system/api/api_config.json") -> Dict[str, Any]:
+ """Load API configuration from file."""
+ try:
+ with open(config_path, 'r') as f:
+ config = json.load(f)
+ logger.info(f"Loaded API config from {config_path}")
+ return config
+ except Exception as e:
+ logger.error(f"Failed to load config: {e}")
+ sys.exit(1)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ """FastAPI lifespan events."""
+ # Startup
+ logger.info("Starting VNA API Server...")
+
+ try:
+ # Load config
+ config = load_config()
+
+ # Set log level
+ log_level = config.get("logging", {}).get("level", "INFO")
+ logging.getLogger().setLevel(getattr(logging, log_level))
+
+ # Start acquisition
+ logger.info("Starting data acquisition...")
+ singletons.vna_data_acquisition_instance.start()
+
+ # Connect processing to acquisition
+ singletons.processing_manager.set_sweep_buffer(singletons.vna_data_acquisition_instance.sweep_buffer)
+ singletons.processing_manager.start()
+ logger.info("Sweep processing started")
+
+ logger.info("VNA API Server started successfully")
+
+ yield
+
+ except Exception as e:
+ logger.error(f"Error during startup: {e}")
+ raise
+
+ # Shutdown
+ logger.info("Shutting down VNA API Server...")
+
+ if singletons.processing_manager:
+ singletons.processing_manager.stop()
+ logger.info("Processing stopped")
+
+ if singletons.vna_data_acquisition_instance and singletons.vna_data_acquisition_instance.is_running:
+ singletons.vna_data_acquisition_instance.stop()
+ logger.info("Acquisition stopped")
+
+ logger.info("VNA API Server shutdown complete")
+
+
+# Create FastAPI app
+app = FastAPI(
+ title="VNA System API",
+ description="Real-time VNA data acquisition and processing API",
+ version="1.0.0",
+ lifespan=lifespan
+)
+
+# Mount static files for web UI
+WEB_UI_DIR = Path(__file__).parent.parent / "web_ui"
+STATIC_DIR = WEB_UI_DIR / "static"
+
+if STATIC_DIR.exists():
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
+ logger.info(f"Mounted static files from: {STATIC_DIR}")
+else:
+ logger.warning(f"Static directory not found: {STATIC_DIR}")
+
+# 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(ws_processing.router)
+
+
+def main():
+ """Main entry point."""
+ config = load_config()
+
+ # Server configuration
+ server_config = config.get("server", {})
+ host = server_config.get("host", "0.0.0.0")
+ port = server_config.get("port", 8000)
+
+ # Start server
+ uvicorn.run(
+ "vna_system.api.main:app",
+ host=host,
+ port=port,
+ log_level="info",
+ reload=False
+ )
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/vna_system/api/websockets/__init__.py b/vna_system/api/websockets/__init__.py
new file mode 100644
index 0000000..27a2e69
--- /dev/null
+++ b/vna_system/api/websockets/__init__.py
@@ -0,0 +1 @@
+"""WebSocket routes module."""
\ No newline at end of file
diff --git a/vna_system/api/websockets/processing.py b/vna_system/api/websockets/processing.py
new file mode 100644
index 0000000..b7614c2
--- /dev/null
+++ b/vna_system/api/websockets/processing.py
@@ -0,0 +1,16 @@
+#!/usr/bin/env python3
+"""
+WebSocket routes for processing data streaming.
+"""
+
+from fastapi import APIRouter, WebSocket
+import vna_system.core.singletons as singletons
+
+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)
\ No newline at end of file
diff --git a/vna_system/api/websockets/websocket_handler.py b/vna_system/api/websockets/websocket_handler.py
new file mode 100644
index 0000000..5af18a5
--- /dev/null
+++ b/vna_system/api/websockets/websocket_handler.py
@@ -0,0 +1,165 @@
+#!/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)
diff --git a/vna_system/binary_logs/config_logs/S11/str100_stp8800_pnts1000_bw1khz.bin b/vna_system/binary_logs/config_logs/S11/str100_stp8800_pnts1000_bw1khz.bin
new file mode 100644
index 0000000..ff27d15
Binary files /dev/null and b/vna_system/binary_logs/config_logs/S11/str100_stp8800_pnts1000_bw1khz.bin differ
diff --git a/vna_system/binary_logs/current_log.bin b/vna_system/binary_logs/current_log.bin
new file mode 100644
index 0000000..ff27d15
Binary files /dev/null and b/vna_system/binary_logs/current_log.bin differ
diff --git a/vna_system/config/config.py b/vna_system/config/config.py
new file mode 100644
index 0000000..5800627
--- /dev/null
+++ b/vna_system/config/config.py
@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+"""
+Configuration file for VNA data acquisition system
+"""
+
+import glob
+import logging
+import serial.tools.list_ports
+
+# Serial communication settings
+DEFAULT_BAUD_RATE = 115200
+DEFAULT_PORT = "/dev/ttyACM0"
+
+# VNA device identification
+VNA_VID = 0x0483 # STMicroelectronics
+VNA_PID = 0x5740 # STM32 Virtual ComPort
+VNA_MANUFACTURER = "STMicroelectronics"
+VNA_PRODUCT = "STM32 Virtual ComPort"
+RX_TIMEOUT = 5.0
+TX_CHUNK_SIZE = 64 * 1024
+
+# Sweep detection and parsing constants
+SWEEP_CMD_LEN = 515
+SWEEP_CMD_PREFIX = bytes([0xAA, 0x00, 0xDA])
+MEAS_HEADER_LEN = 21
+MEAS_CMDS_PER_SWEEP = 17
+EXPECTED_POINTS_PER_SWEEP = 1000
+
+# Buffer settings
+SWEEP_BUFFER_MAX_SIZE = 100 # Maximum number of sweeps to store in circular buffer
+SERIAL_BUFFER_SIZE = 512 * 1024
+
+# Log file settings
+BIN_LOG_FILE_PATH = "./vna_system/binary_logs/current_log.bin" # Symbolic link to the current log file
+
+# Binary log format constants
+MAGIC = b"VNALOG1\n"
+DIR_TO_DEV = 0x01 # '>'
+DIR_FROM_DEV = 0x00 # '<'
+
+# File I/O settings
+FILE_CHUNK_SIZE = 256 * 1024
+SERIAL_PEEK_SIZE = 32
+
+# Timeout settings
+SERIAL_IDLE_TIMEOUT = 0.5
+SERIAL_DRAIN_DELAY = 0.05
+SERIAL_DRAIN_CHECK_DELAY = 0.01
+SERIAL_CONNECT_DELAY = 0.5
+
+
+def find_vna_port():
+ """
+ Automatically find VNA device port.
+
+ Returns:
+ str: Port path (e.g., '/dev/ttyACM1') or None if not found
+ """
+ logger = logging.getLogger(__name__)
+
+ # Method 1: Use pyserial port detection by VID/PID
+ try:
+ ports = list(serial.tools.list_ports.comports())
+ logger.debug(f"Found {len(ports)} serial ports")
+
+ for port in ports:
+ logger.debug(f"Checking port {port.device}: VID={port.vid:04X} PID={port.pid:04X} "
+ f"Manufacturer='{port.manufacturer}' Product='{port.description}'")
+
+ # Check by VID/PID
+ if port.vid == VNA_VID and port.pid == VNA_PID:
+ logger.info(f"Found VNA device by VID/PID at {port.device}")
+ return port.device
+
+ # Fallback: Check by manufacturer/product strings
+ if (port.manufacturer and VNA_MANUFACTURER.lower() in port.manufacturer.lower() and
+ port.description and VNA_PRODUCT.lower() in port.description.lower()):
+ logger.info(f"Found VNA device by description at {port.device}")
+ return port.device
+
+ except Exception as e:
+ logger.warning(f"Error during VID/PID port detection: {e}")
+
+ # Method 2: Search ttyACM devices (Linux-specific)
+ try:
+ acm_ports = glob.glob('/dev/ttyACM*')
+ logger.debug(f"Found ACM ports: {acm_ports}")
+
+ if acm_ports:
+ # Sort to get consistent ordering (ttyACM0, ttyACM1, etc.)
+ acm_ports.sort()
+ logger.info(f"Using first available ACM port: {acm_ports[0]}")
+ return acm_ports[0]
+
+ except Exception as e:
+ logger.warning(f"Error during ACM port detection: {e}")
+
+ # Method 3: Fallback to default
+ logger.warning(f"VNA device not found, using default port: {DEFAULT_PORT}")
+ return DEFAULT_PORT
+
+
+def get_vna_port():
+ """
+ Get VNA port, trying auto-detection first, then falling back to default.
+
+ Returns:
+ str: Port path to use for VNA connection
+ """
+ logger = logging.getLogger(__name__)
+
+ try:
+ port = find_vna_port()
+ if port and port != DEFAULT_PORT:
+ logger.info(f"Auto-detected VNA port: {port}")
+ return port
+ except Exception as e:
+ logger.error(f"Port detection failed: {e}")
+ logger.info(f"Using default port: {DEFAULT_PORT}")
+ return DEFAULT_PORT
\ No newline at end of file
diff --git a/vna_system/core/acquisition/data_acquisition.py b/vna_system/core/acquisition/data_acquisition.py
new file mode 100644
index 0000000..cc49bd8
--- /dev/null
+++ b/vna_system/core/acquisition/data_acquisition.py
@@ -0,0 +1,405 @@
+from __future__ import annotations
+
+import io
+import logging
+import os
+import struct
+import threading
+import time
+from typing import BinaryIO, List, Optional, Tuple
+
+import serial
+
+# Avoid wildcard imports: make config access explicit and discoverable.
+from vna_system.config import config as cfg
+from vna_system.core.acquisition.sweep_buffer import SweepBuffer
+
+
+logger = logging.getLogger(__name__)
+
+
+class VNADataAcquisition:
+ """Main data acquisition class with asynchronous sweep collection."""
+
+ def __init__(self, port: str = None, baud: int = cfg.DEFAULT_BAUD_RATE) -> None:
+ self.bin_log_path: str = cfg.BIN_LOG_FILE_PATH
+
+ # Auto-detect port if not specified
+ if port is None:
+ self.port: str = cfg.get_vna_port()
+ logger.info(f"Using auto-detected port: {self.port}")
+ else:
+ self.port: str = port
+ logger.info(f"Using specified port: {self.port}")
+
+ self.baud: int = baud
+
+ self._sweep_buffer = SweepBuffer()
+ self.ser: Optional[serial.SerialBase] = None
+
+ # Control flags
+ self._running: bool = False
+ self._thread: Optional[threading.Thread] = None
+ self._stop_event: threading.Event = threading.Event()
+
+ # Sweep collection state
+ self._collecting: bool = False
+ self._collected_rx_payloads: List[bytes] = []
+ self._meas_cmds_in_sweep: int = 0
+
+ # --------------------------------------------------------------------- #
+ # Lifecycle
+ # --------------------------------------------------------------------- #
+
+ def start(self) -> None:
+ """Start the data acquisition background thread."""
+ if self._running:
+ logger.debug("Acquisition already running; start() call ignored.")
+ return
+
+ self._running = True
+ self._stop_event.clear()
+ self._thread = threading.Thread(target=self._acquisition_loop, daemon=True)
+ self._thread.start()
+ logger.info("Acquisition thread started.")
+
+ def stop(self) -> None:
+ """Stop the data acquisition background thread."""
+ if not self._running:
+ logger.debug("Acquisition 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("Acquisition thread joined.")
+
+ self._close_serial()
+
+ @property
+ def is_running(self) -> bool:
+ """Return True if the acquisition thread is alive."""
+ return self._running and (self._thread is not None and self._thread.is_alive())
+
+ @property
+ def sweep_buffer(self) -> SweepBuffer:
+ """Return a reference to the sweep buffer."""
+ return self._sweep_buffer
+
+ def get_device_info(self) -> dict:
+ """Get information about the connected VNA device."""
+ info = {
+ 'port': self.port,
+ 'baud_rate': self.baud,
+ 'is_connected': self.ser is not None and self.ser.is_open if self.ser else False,
+ 'is_running': self.is_running,
+ 'device_detected': False,
+ 'device_details': None
+ }
+
+ # Try to get device details from port detection
+ try:
+ from serial.tools.list_ports import comports
+ for port in comports():
+ if port.device == self.port:
+ info['device_detected'] = True
+ info['device_details'] = {
+ 'vid': f"0x{port.vid:04X}" if port.vid else None,
+ 'pid': f"0x{port.pid:04X}" if port.pid else None,
+ 'manufacturer': port.manufacturer,
+ 'product': port.description,
+ 'serial_number': port.serial_number
+ }
+ break
+ except Exception as e:
+ logger.debug(f"Could not get device details: {e}")
+
+ return info
+
+ # --------------------------------------------------------------------- #
+ # Serial management
+ # --------------------------------------------------------------------- #
+
+ def _connect_serial(self) -> bool:
+ """Establish the serial connection."""
+ try:
+ logger.info(f"Attempting to connect to VNA device at {self.port} @ {self.baud} baud")
+
+ # Check if port exists
+ import os
+ if not os.path.exists(self.port):
+ logger.error(f"Port {self.port} does not exist. Device not connected?")
+ return False
+
+ # Check permissions
+ if not os.access(self.port, os.R_OK | os.W_OK):
+ logger.error(f"No read/write permissions for {self.port}. Try: sudo chmod 666 {self.port}")
+ logger.error("Or add your user to the dialout group: sudo usermod -a -G dialout $USER")
+ return False
+
+ self.ser = serial.Serial(self.port, baudrate=self.baud, timeout=0)
+ time.sleep(cfg.SERIAL_CONNECT_DELAY)
+ self._drain_serial_input()
+ logger.info(f"Serial connection established to {self.port}")
+ return True
+ except PermissionError as exc:
+ logger.error(f"Permission denied for {self.port}: {exc}")
+ logger.error("Try: sudo chmod 666 {self.port} or add user to dialout group")
+ return False
+ except serial.SerialException as exc:
+ logger.error(f"Serial connection failed for {self.port}: {exc}")
+ return False
+ except Exception as exc: # noqa: BLE001 (keep broad to preserve behavior)
+ logger.error(f"Unexpected error connecting to {self.port}: {exc}")
+ return False
+
+ def _close_serial(self) -> None:
+ """Close the serial connection if open."""
+ if self.ser:
+ try:
+ self.ser.close()
+ logger.debug("Serial connection closed.")
+ except Exception: # noqa: BLE001
+ # Preserve original behavior: swallow any serial close errors.
+ logger.debug("Ignoring error while closing serial.", exc_info=True)
+ finally:
+ self.ser = None
+
+ def _drain_serial_input(self) -> None:
+ """Drain any pending bytes from the serial input buffer."""
+ if not self.ser:
+ return
+
+ drained = 0
+ while True:
+ bytes_waiting = getattr(self.ser, "in_waiting", 0)
+ if bytes_waiting <= 0:
+ break
+ drained += len(self.ser.read(bytes_waiting))
+ time.sleep(cfg.SERIAL_DRAIN_CHECK_DELAY)
+ if drained:
+ logger.debug("Drained %d pending byte(s) from serial input.", drained)
+
+ # --------------------------------------------------------------------- #
+ # Acquisition loop
+ # --------------------------------------------------------------------- #
+
+ def _acquisition_loop(self) -> None:
+ """Main acquisition loop executed by the background thread."""
+ while self._running and not self._stop_event.is_set():
+ try:
+ if not self._connect_serial():
+ time.sleep(1.0)
+ continue
+
+ with open(self.bin_log_path, "rb") as raw:
+ buffered = io.BufferedReader(raw, buffer_size=cfg.SERIAL_BUFFER_SIZE)
+ self._drain_serial_input()
+ self._process_sweep_data(buffered)
+
+ except Exception as exc: # noqa: BLE001
+ logger.error("Acquisition error: %s", exc)
+ time.sleep(1.0)
+ finally:
+ self._close_serial()
+
+ # --------------------------------------------------------------------- #
+ # Log processing
+ # --------------------------------------------------------------------- #
+
+ def _process_sweep_data(self, f: BinaryIO) -> None:
+ """Process the binary log file and collect sweep data one sweep at a time."""
+ while self._running and not self._stop_event.is_set():
+ try:
+ # Start from beginning of file for each sweep
+ f.seek(0)
+
+ # Validate header
+ header = self._read_exact(f, len(cfg.MAGIC))
+ if header != cfg.MAGIC:
+ raise ValueError("Invalid log format: MAGIC header mismatch.")
+
+ self._reset_sweep_state()
+
+ # Process one complete sweep
+ sweep_completed = False
+ while not sweep_completed and self._running and not self._stop_event.is_set():
+ # Read record header
+ dir_byte = f.read(1)
+ if not dir_byte:
+ # EOF reached without completing sweep - wait and retry
+ logger.debug("EOF reached, waiting for more data...")
+ time.sleep(0.1)
+ break
+
+ direction = dir_byte[0]
+ (length,) = struct.unpack(">I", self._read_exact(f, 4))
+
+ if direction == cfg.DIR_TO_DEV:
+ # TX path: stream to device and inspect for sweep start
+ first = self._serial_write_from_file(f, length)
+
+ if not self._collecting and self._is_sweep_start_command(length, first):
+ self._collecting = True
+ self._collected_rx_payloads = []
+ self._meas_cmds_in_sweep = 0
+ logger.info("Starting sweep data collection from device")
+
+ elif direction == cfg.DIR_FROM_DEV:
+ # RX path: read exact number of bytes from device
+ rx_bytes = self._serial_read_exact(length, capture=self._collecting)
+ self._skip_bytes(f, length) # Keep log file pointer in sync
+
+ if self._collecting:
+ self._collected_rx_payloads.append(rx_bytes)
+ self._meas_cmds_in_sweep += 1
+
+ # Check for sweep completion
+ if self._meas_cmds_in_sweep >= cfg.MEAS_CMDS_PER_SWEEP:
+ self._finalize_sweep()
+ sweep_completed = True
+
+ except Exception as exc: # noqa: BLE001
+ logger.error("Processing error: %s", exc)
+ time.sleep(1.0)
+
+ def _finalize_sweep(self) -> None:
+ """Parse collected payloads into points and push to the buffer."""
+ all_points: List[Tuple[float, float]] = []
+ for payload in self._collected_rx_payloads:
+ all_points.extend(self._parse_measurement_data(payload))
+
+ if all_points:
+ sweep_number = self._sweep_buffer.add_sweep(all_points)
+ logger.info(f"Collected sweep #{sweep_number} with {len(all_points)} data points")
+ if len(all_points) != cfg.EXPECTED_POINTS_PER_SWEEP:
+ logger.warning(
+ "Expected %d points, got %d.",
+ cfg.EXPECTED_POINTS_PER_SWEEP,
+ len(all_points),
+ )
+
+ self._reset_sweep_state()
+
+ def _reset_sweep_state(self) -> None:
+ """Reset internal state for the next sweep collection."""
+ self._collecting = False
+ self._collected_rx_payloads = []
+ self._meas_cmds_in_sweep = 0
+
+ # --------------------------------------------------------------------- #
+ # I/O helpers
+ # --------------------------------------------------------------------- #
+
+ def _read_exact(self, f: BinaryIO, n: int) -> bytes:
+ """Read exactly *n* bytes from a file-like object or raise EOFError."""
+ buf = bytearray()
+ while len(buf) < n:
+ chunk = f.read(n - len(buf))
+ if not chunk:
+ raise EOFError(f"Unexpected EOF while reading {n} bytes.")
+ buf += chunk
+ return bytes(buf)
+
+ def _skip_bytes(self, f: BinaryIO, n: int) -> None:
+ """Skip *n* bytes in a file-like object efficiently."""
+ if hasattr(f, "seek"):
+ try:
+ f.seek(n, os.SEEK_CUR)
+ return
+ except (OSError, io.UnsupportedOperation):
+ # Fall back to manual skipping below.
+ pass
+
+ remaining = n
+ while remaining > 0:
+ chunk = f.read(min(cfg.FILE_CHUNK_SIZE, remaining))
+ if not chunk:
+ raise EOFError(f"Unexpected EOF while skipping {n} bytes.")
+ remaining -= len(chunk)
+
+ def _serial_write_from_file(self, f: BinaryIO, nbytes: int) -> bytes:
+ """
+ Stream *nbytes* from a file-like object to the serial port.
+
+ Returns the first few bytes (up to cfg.SERIAL_PEEK_SIZE) for command inspection.
+ """
+ first = bytearray()
+ remaining = nbytes
+
+ while remaining > 0:
+ to_read = min(cfg.TX_CHUNK_SIZE, remaining)
+ chunk = f.read(to_read)
+ if not chunk:
+ raise EOFError("Log truncated while sending.")
+
+ # Capture a peek for command inspection
+ needed = max(0, cfg.SERIAL_PEEK_SIZE - len(first))
+ if needed:
+ first.extend(chunk[:needed])
+
+ # Write to serial
+ written = 0
+ while written < len(chunk):
+ if not self.ser:
+ break
+ n = self.ser.write(chunk[written:])
+ if n is None:
+ n = 0
+ written += n
+
+ remaining -= len(chunk)
+
+ return bytes(first)
+
+ def _serial_read_exact(self, nbytes: int, capture: bool = False) -> bytes:
+ """Read exactly *nbytes* from the serial port; optionally capture and return them."""
+ if not self.ser:
+ return b""
+
+ deadline = time.monotonic() + cfg.RX_TIMEOUT
+ total = 0
+ out = bytearray() if capture else None
+ old_timeout = self.ser.timeout
+ self.ser.timeout = min(cfg.SERIAL_IDLE_TIMEOUT, cfg.RX_TIMEOUT)
+
+ try:
+ while total < nbytes:
+ if time.monotonic() >= deadline:
+ raise TimeoutError(f"Timeout while waiting for {nbytes} bytes.")
+ chunk = self.ser.read(nbytes - total)
+ if chunk:
+ total += len(chunk)
+ if capture and out is not None:
+ out.extend(chunk)
+ return bytes(out) if (capture and out is not None) else b""
+ finally:
+ self.ser.timeout = old_timeout
+
+ # --------------------------------------------------------------------- #
+ # Parsing & detection
+ # --------------------------------------------------------------------- #
+
+ def _parse_measurement_data(self, payload: bytes) -> List[Tuple[float, float]]:
+ """Parse complex measurement samples (float32 pairs) from a payload."""
+ if len(payload) <= cfg.MEAS_HEADER_LEN:
+ return []
+
+ data = memoryview(payload)[cfg.MEAS_HEADER_LEN:]
+ out: List[Tuple[float, float]] = []
+ n_pairs = len(data) // 8 # 2 × float32 per point
+
+ for i in range(n_pairs):
+ off = i * 8
+ real = struct.unpack_from(" bool:
+ """Return True if a TX command indicates the start of a sweep."""
+ return tx_len == cfg.SWEEP_CMD_LEN and first_bytes.startswith(cfg.SWEEP_CMD_PREFIX)
+
diff --git a/vna_system/core/acquisition/sweep_buffer.py b/vna_system/core/acquisition/sweep_buffer.py
new file mode 100644
index 0000000..9f45ae8
--- /dev/null
+++ b/vna_system/core/acquisition/sweep_buffer.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+"""
+Sweep data structures and buffer management for VNA data acquisition.
+
+This module contains classes for storing and managing sweep data in a thread-safe manner.
+"""
+
+import math
+import threading
+import time
+from collections import deque
+from dataclasses import dataclass
+from typing import List, Tuple, Optional, Dict, Any
+
+from vna_system.config.config import SWEEP_BUFFER_MAX_SIZE
+
+
+@dataclass
+class SweepData:
+ """Container for a single sweep with metadata"""
+ sweep_number: int
+ timestamp: float
+ points: List[Tuple[float, float]] # Complex pairs (real, imag)
+ total_points: int
+
+ @property
+ def magnitude_phase_data(self) -> List[Tuple[float, float, float, float]]:
+ """Convert to magnitude/phase representation"""
+ result = []
+ for real, imag in self.points:
+ magnitude = (real * real + imag * imag) ** 0.5
+ phase = math.atan2(imag, real) if (real != 0.0 or imag != 0.0) else 0.0
+ result.append((real, imag, magnitude, phase))
+ return result
+
+
+class SweepBuffer:
+ """Thread-safe circular buffer for sweep data"""
+
+ def __init__(self, max_size: int = SWEEP_BUFFER_MAX_SIZE, initial_sweep_number: int = 0):
+ self._buffer = deque(maxlen=max_size)
+ self._lock = threading.RLock()
+ self._sweep_counter = initial_sweep_number
+
+ def add_sweep(self, points: List[Tuple[float, float]]) -> int:
+ """Add a new sweep to the buffer and return its number"""
+ with self._lock:
+ self._sweep_counter += 1
+ sweep = SweepData(
+ sweep_number=self._sweep_counter,
+ timestamp=time.time(),
+ points=points,
+ total_points=len(points)
+ )
+ self._buffer.append(sweep)
+ return self._sweep_counter
+
+ def get_latest_sweep(self) -> Optional[SweepData]:
+ """Get the most recent sweep"""
+ with self._lock:
+ return self._buffer[-1] if self._buffer else None
+
+ def get_sweep_by_number(self, sweep_number: int) -> Optional[SweepData]:
+ """Get a specific sweep by its number"""
+ with self._lock:
+ for sweep in reversed(self._buffer):
+ if sweep.sweep_number == sweep_number:
+ return sweep
+ return None
+
+ def get_all_sweeps(self) -> List[SweepData]:
+ """Get all sweeps currently in buffer"""
+ with self._lock:
+ return list(self._buffer)
+
+ def get_buffer_info(self) -> Dict[str, Any]:
+ """Get buffer status information"""
+ with self._lock:
+ return {
+ 'current_size': len(self._buffer),
+ 'max_size': self._buffer.maxlen,
+ 'total_sweeps_processed': self._sweep_counter,
+ 'latest_sweep_number': self._buffer[-1].sweep_number if self._buffer else None,
+ 'oldest_sweep_number': self._buffer[0].sweep_number if self._buffer else None,
+ 'next_sweep_number': self._sweep_counter + 1
+ }
+
+ 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
\ No newline at end of file
diff --git a/vna_system/core/processing/__init__.py b/vna_system/core/processing/__init__.py
new file mode 100644
index 0000000..d678fc3
--- /dev/null
+++ b/vna_system/core/processing/__init__.py
@@ -0,0 +1 @@
+"""Sweep data processing module."""
\ No newline at end of file
diff --git a/vna_system/core/processing/base_processor.py b/vna_system/core/processing/base_processor.py
new file mode 100644
index 0000000..14900c0
--- /dev/null
+++ b/vna_system/core/processing/base_processor.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+"""
+Base sweep processor interface and utilities.
+"""
+
+from __future__ import annotations
+
+import abc
+from typing import Any, Dict, List, Optional
+
+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) -> Optional[ProcessingResult]:
+ """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: Optional[Dict[str, Any]] = None,
+ file_path: Optional[str] = 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,
+ }
\ No newline at end of file
diff --git a/vna_system/core/processing/config.json b/vna_system/core/processing/config.json
new file mode 100644
index 0000000..3310563
--- /dev/null
+++ b/vna_system/core/processing/config.json
@@ -0,0 +1,20 @@
+{
+ "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
+ }
+}
\ No newline at end of file
diff --git a/vna_system/core/processing/processors/__init__.py b/vna_system/core/processing/processors/__init__.py
new file mode 100644
index 0000000..101db2b
--- /dev/null
+++ b/vna_system/core/processing/processors/__init__.py
@@ -0,0 +1 @@
+"""Sweep data processors."""
\ No newline at end of file
diff --git a/vna_system/core/processing/processors/magnitude_plot.py b/vna_system/core/processing/processors/magnitude_plot.py
new file mode 100644
index 0000000..86f4f45
--- /dev/null
+++ b/vna_system/core/processing/processors/magnitude_plot.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+"""
+Magnitude plot processor for sweep data.
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Any, Dict, List, Optional, 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
+
+
+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)
+
+ # 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) -> Optional[ProcessingResult]:
+ """Process sweep data and create magnitude plot."""
+ if not self.should_process(sweep):
+ return None
+
+ # Extract magnitude data
+ magnitude_data = self._extract_magnitude_data(sweep)
+ if not magnitude_data:
+ return None
+
+ # Create plotly figure for websocket/API consumption
+ plotly_fig = self._create_plotly_figure(sweep, magnitude_data)
+
+ # Save image if requested (using matplotlib)
+ file_path = None
+ if self.save_image:
+ file_path = self._save_matplotlib_image(sweep, magnitude_data)
+
+ # 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),
+ }
+
+ 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 _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]]) -> 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()
+
+ fig.add_trace(go.Scatter(
+ x=indices,
+ y=magnitudes,
+ mode='lines',
+ name='Magnitude',
+ line=dict(color='blue', width=2)
+ ))
+
+ fig.update_layout(
+ title=f'Magnitude Plot - Sweep #{sweep.sweep_number}',
+ 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]]) -> Optional[Path]:
+ """Save plot as image file using matplotlib."""
+ try:
+ filename = f"magnitude_sweep_{sweep.sweep_number:06d}.{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)
+ ax.plot(indices, magnitudes, 'b-', linewidth=2, label='Magnitude')
+
+ ax.set_title(f'Magnitude Plot - Sweep #{sweep.sweep_number}')
+ 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)),
+ }
\ No newline at end of file
diff --git a/vna_system/core/processing/results_storage.py b/vna_system/core/processing/results_storage.py
new file mode 100644
index 0000000..aa125ef
--- /dev/null
+++ b/vna_system/core/processing/results_storage.py
@@ -0,0 +1,295 @@
+from __future__ import annotations
+
+import json
+import logging
+import threading
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+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: Optional[str] = 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: Optional[str] = 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) -> Optional[Dict[str, Any]]:
+ """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: Optional[int] = 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())
\ No newline at end of file
diff --git a/vna_system/core/processing/sweep_processor.py b/vna_system/core/processing/sweep_processor.py
new file mode 100644
index 0000000..026db98
--- /dev/null
+++ b/vna_system/core/processing/sweep_processor.py
@@ -0,0 +1,231 @@
+from __future__ import annotations
+
+import json
+import logging
+import queue
+import threading
+import time
+from typing import Any, Dict, List, Optional
+
+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: Optional[SweepBuffer] = None
+
+ # Processing control
+ self._running = False
+ self._thread: Optional[threading.Thread] = 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: Optional[float] = None) -> Optional[ProcessingResult]:
+ """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: Optional[str] = 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: Optional[str] = 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,
+ }
\ No newline at end of file
diff --git a/vna_system/core/singletons.py b/vna_system/core/singletons.py
new file mode 100644
index 0000000..a5393e6
--- /dev/null
+++ b/vna_system/core/singletons.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python3
+"""
+Global singleton instances for the VNA system.
+
+This module centralizes all singleton instances to avoid global variables
+scattered throughout the codebase.
+"""
+from vna_system.core.acquisition.data_acquisition import VNADataAcquisition
+from vna_system.core.processing.sweep_processor import SweepProcessingManager
+from vna_system.api.websockets.websocket_handler import WebSocketManager
+
+# Global singleton instances
+vna_data_acquisition_instance: VNADataAcquisition = VNADataAcquisition()
+processing_manager: SweepProcessingManager = SweepProcessingManager()
+websocket_manager: WebSocketManager = WebSocketManager(processing_manager)
\ No newline at end of file
diff --git a/vna_system/scripts/__init__.py b/vna_system/scripts/__init__.py
new file mode 100644
index 0000000..55dbf87
--- /dev/null
+++ b/vna_system/scripts/__init__.py
@@ -0,0 +1 @@
+"""VNA System scripts module."""
\ No newline at end of file
diff --git a/vna_system/scripts/start.sh b/vna_system/scripts/start.sh
new file mode 100755
index 0000000..097af56
--- /dev/null
+++ b/vna_system/scripts/start.sh
@@ -0,0 +1,92 @@
+#!/bin/bash
+#
+# VNA System Startup Script
+#
+# This script starts the VNA System API server with proper environment setup
+#
+
+set -e # Exit on any error
+
+# Get script directory
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Logging functions
+log_info() {
+ echo -e "${BLUE}[INFO]${NC} $1"
+}
+
+log_success() {
+ echo -e "${GREEN}[SUCCESS]${NC} $1"
+}
+
+log_warning() {
+ echo -e "${YELLOW}[WARNING]${NC} $1"
+}
+
+log_error() {
+ echo -e "${RED}[ERROR]${NC} $1"
+}
+
+# Check if we're in the right directory
+if [ ! -f "$PROJECT_ROOT/vna_system/api/main.py" ]; then
+ log_error "VNA System main.py not found. Please run this script from the project directory."
+ exit 1
+fi
+
+# Change to project root
+cd "$PROJECT_ROOT"
+
+log_info "Starting VNA System from: $PROJECT_ROOT"
+
+# Check Python version
+if ! command -v python3 &> /dev/null; then
+ log_error "Python 3 is not installed or not in PATH"
+ exit 1
+fi
+
+PYTHON_VERSION=$(python3 --version 2>&1 | cut -d' ' -f2)
+log_info "Using Python version: $PYTHON_VERSION"
+
+# Check if virtual environment exists
+if [ -d "venv" ] || [ -d ".venv" ]; then
+ if [ -d "venv" ]; then
+ VENV_PATH="venv"
+ else
+ VENV_PATH=".venv"
+ fi
+
+ log_info "Found virtual environment at: $VENV_PATH"
+ log_info "Activating virtual environment..."
+
+ # Activate virtual environment
+ source "$VENV_PATH/bin/activate"
+ log_success "Virtual environment activated"
+else
+ log_warning "No virtual environment found. Using system Python."
+ log_warning "Consider creating a virtual environment: python3 -m venv venv"
+fi
+
+# Check configuration files
+if [ ! -f "vna_system/api/api_config.json" ]; then
+ log_warning "API config not found. Using defaults."
+fi
+
+if [ ! -f "vna_system/core/processing/config.json" ]; then
+ log_warning "Processing config not found. Some processors may not work."
+fi
+
+# Start the server
+log_info "Starting VNA System API server..."
+log_info "Press Ctrl+C to stop the server"
+echo
+
+# Run the main application
+exec python3 -m vna_system.api.main
\ No newline at end of file
diff --git a/vna_system/web_ui/static/assets/favicon.svg b/vna_system/web_ui/static/assets/favicon.svg
new file mode 100644
index 0000000..94949c4
--- /dev/null
+++ b/vna_system/web_ui/static/assets/favicon.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/vna_system/web_ui/static/css/charts.css b/vna_system/web_ui/static/css/charts.css
new file mode 100644
index 0000000..14c3f7a
--- /dev/null
+++ b/vna_system/web_ui/static/css/charts.css
@@ -0,0 +1,242 @@
+/* Chart-specific styles */
+.plotly-chart {
+ background-color: transparent !important;
+}
+
+/* Custom Plotly theme overrides */
+.js-plotly-plot .plotly {
+ background-color: transparent !important;
+}
+
+/* Plotly modebar styling - simplified to ensure visibility */
+.modebar {
+ background-color: rgba(51, 65, 85, 0.9) !important;
+ border: 1px solid rgba(71, 85, 105, 0.8) !important;
+ border-radius: 8px !important;
+ padding: 4px !important;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3) !important;
+ z-index: 1000 !important;
+ position: relative !important;
+}
+
+.modebar-btn {
+ background-color: transparent !important;
+ border: none !important;
+ border-radius: 4px !important;
+ color: #cbd5e1 !important;
+ padding: 3px !important;
+ margin: 1px !important;
+ width: 24px !important;
+ height: 24px !important;
+ cursor: pointer !important;
+}
+
+.modebar-btn:hover {
+ background-color: rgba(71, 85, 105, 0.8) !important;
+ color: #f1f5f9 !important;
+}
+
+.modebar-btn.active {
+ background-color: #3b82f6 !important;
+ color: white !important;
+}
+
+/* Fullscreen chart styles */
+.chart-card:fullscreen {
+ width: 100vw !important;
+ height: 100vh !important;
+ max-width: none !important;
+ max-height: none !important;
+ background-color: var(--bg-primary) !important;
+ border: none !important;
+ border-radius: 0 !important;
+ display: flex !important;
+ flex-direction: column !important;
+}
+
+.chart-card:fullscreen .chart-card__header {
+ flex-shrink: 0;
+ padding: var(--space-3) var(--space-4);
+}
+
+.chart-card:fullscreen .chart-card__content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: var(--space-2);
+ min-height: 0;
+}
+
+.chart-card:fullscreen .chart-card__plot {
+ flex: 1 !important;
+ width: 100% !important;
+ height: 100% !important;
+ min-height: 0 !important;
+}
+
+.chart-card:fullscreen .chart-card__plot .js-plotly-plot {
+ width: 100% !important;
+ height: 100% !important;
+}
+
+.chart-card:fullscreen .chart-card__plot .plotly .svg-container {
+ width: 100% !important;
+ height: 100% !important;
+}
+
+.chart-card:fullscreen .chart-card__meta {
+ flex-shrink: 0;
+ padding: var(--space-2) var(--space-4);
+}
+
+/* Chart loading state */
+.chart-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 400px;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.chart-loading__spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid var(--border-primary);
+ border-top: 3px solid var(--color-primary-500);
+ border-radius: var(--radius-full);
+ animation: spin 1s linear infinite;
+}
+
+.chart-loading__text {
+ font-size: var(--font-size-sm);
+ color: var(--text-tertiary);
+}
+
+/* Chart error state */
+.chart-error {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 400px;
+ flex-direction: column;
+ gap: var(--space-3);
+ text-align: center;
+ padding: var(--space-6);
+}
+
+.chart-error__icon {
+ width: 48px;
+ height: 48px;
+ color: var(--color-error-500);
+}
+
+.chart-error__title {
+ font-size: var(--font-size-lg);
+ font-weight: var(--font-weight-semibold);
+ color: var(--text-primary);
+}
+
+.chart-error__message {
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+ max-width: 300px;
+ line-height: var(--line-height-relaxed);
+}
+
+/* Chart animations */
+@keyframes chartFadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(20px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+.chart-card--animated {
+ animation: chartFadeIn 0.5s ease;
+}
+
+/* Plotly theme customization */
+.chart-card .js-plotly-plot {
+ border-radius: 0;
+ overflow: hidden;
+}
+
+/* Custom scrollbar for chart containers */
+.chart-card__content::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+.chart-card__content::-webkit-scrollbar-track {
+ background-color: var(--bg-primary);
+ border-radius: var(--radius-full);
+}
+
+.chart-card__content::-webkit-scrollbar-thumb {
+ background-color: var(--border-secondary);
+ border-radius: var(--radius-full);
+ border: 1px solid var(--bg-primary);
+}
+
+.chart-card__content::-webkit-scrollbar-thumb:hover {
+ background-color: var(--text-tertiary);
+}
+
+/* Responsive chart adjustments */
+@media (max-width: 768px) {
+ .chart-card__plot {
+ height: 320px !important;
+ min-height: 300px;
+ }
+
+ .chart-card__content {
+ min-height: 350px;
+ }
+
+ .chart-loading,
+ .chart-error {
+ min-height: 320px;
+ }
+}
+
+/* Chart grid responsive behavior */
+@media (max-width: 1200px) {
+ .charts-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (min-width: 1400px) {
+ .charts-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (min-width: 1800px) {
+ .charts-grid {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+/* Print styles */
+@media print {
+ .chart-card__header,
+ .chart-card__meta,
+ .modebar {
+ display: none !important;
+ }
+
+ .chart-card {
+ break-inside: avoid;
+ page-break-inside: avoid;
+ }
+
+ .chart-card__content {
+ padding: 0;
+ }
+}
\ No newline at end of file
diff --git a/vna_system/web_ui/static/css/components.css b/vna_system/web_ui/static/css/components.css
new file mode 100644
index 0000000..c6fe495
--- /dev/null
+++ b/vna_system/web_ui/static/css/components.css
@@ -0,0 +1,461 @@
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ padding: var(--space-2-5) var(--space-4);
+ border-radius: var(--radius-lg);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ line-height: 1;
+ text-decoration: none;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ border: 1px solid transparent;
+ white-space: nowrap;
+}
+
+.btn--sm {
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-size-xs);
+}
+
+.btn--lg {
+ padding: var(--space-3) var(--space-6);
+ font-size: var(--font-size-base);
+}
+
+/* Button Variants */
+.btn--primary {
+ background-color: var(--color-primary-600);
+ color: white;
+}
+
+.btn--primary:hover {
+ background-color: var(--color-primary-700);
+}
+
+.btn--bordered {
+ border: 2px solid currentColor;
+ box-shadow: var(--shadow-sm);
+}
+
+.btn--primary.btn--bordered {
+ border-color: var(--color-primary-500);
+ box-shadow: 0 0 0 1px var(--color-primary-600), var(--shadow-sm);
+}
+
+.btn--primary.btn--bordered:hover {
+ box-shadow: 0 0 0 1px var(--color-primary-500), var(--shadow-md);
+ transform: translateY(-1px);
+}
+
+.btn--secondary {
+ background-color: var(--bg-tertiary);
+ color: var(--text-primary);
+ border-color: var(--border-primary);
+}
+
+.btn--secondary:hover {
+ background-color: var(--bg-surface-hover);
+}
+
+.btn--ghost {
+ background-color: transparent;
+ color: var(--text-secondary);
+}
+
+.btn--ghost:hover {
+ background-color: var(--bg-surface-hover);
+ color: var(--text-primary);
+}
+
+.btn i {
+ width: 14px;
+ height: 14px;
+}
+
+.btn--sm i {
+ width: 12px;
+ height: 12px;
+}
+
+.btn--lg i {
+ width: 16px;
+ height: 16px;
+}
+
+/* Processor Toggles */
+.processor-toggles {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+
+.processor-toggle {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ background-color: var(--bg-tertiary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-lg);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.processor-toggle:hover {
+ background-color: var(--bg-surface-hover);
+}
+
+.processor-toggle--active {
+ background-color: var(--color-primary-900);
+ border-color: var(--color-primary-600);
+ color: var(--color-primary-300);
+}
+
+.processor-toggle__checkbox {
+ position: relative;
+ width: 16px;
+ height: 16px;
+ border: 2px solid var(--border-secondary);
+ border-radius: var(--radius-sm);
+ transition: all var(--transition-fast);
+}
+
+.processor-toggle--active .processor-toggle__checkbox {
+ background-color: var(--color-primary-500);
+ border-color: var(--color-primary-500);
+}
+
+.processor-toggle__checkbox::after {
+ content: '';
+ position: absolute;
+ top: 1px;
+ left: 4px;
+ width: 4px;
+ height: 8px;
+ border: solid white;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+ opacity: 0;
+ transition: opacity var(--transition-fast);
+}
+
+.processor-toggle--active .processor-toggle__checkbox::after {
+ opacity: 1;
+}
+
+.processor-toggle__label {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ color: var(--text-secondary);
+ transition: color var(--transition-fast);
+}
+
+.processor-toggle--active .processor-toggle__label {
+ color: var(--color-primary-300);
+}
+
+.processor-toggle__count {
+ font-size: var(--font-size-xs);
+ color: var(--text-tertiary);
+ background-color: var(--bg-primary);
+ padding: var(--space-0-5) var(--space-1-5);
+ border-radius: var(--radius-full);
+ min-width: 20px;
+ text-align: center;
+}
+
+/* Display Controls */
+.display-controls {
+ display: flex;
+ gap: var(--space-2);
+}
+
+/* Connection Info */
+.connection-info {
+ display: flex;
+ gap: var(--space-4);
+}
+
+.info-item {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ align-items: center;
+}
+
+.info-label {
+ font-size: var(--font-size-xs);
+ color: var(--text-tertiary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.info-value {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+}
+
+/* Chart Card */
+.chart-card {
+ background-color: var(--bg-surface);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-xl);
+ overflow: visible;
+ transition: all var(--transition-fast);
+}
+
+.chart-card:hover {
+ border-color: var(--border-secondary);
+ box-shadow: var(--shadow-md);
+}
+
+.chart-card--hidden {
+ display: none;
+}
+
+.chart-card__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-4) var(--space-5);
+ background-color: var(--bg-tertiary);
+ border-bottom: 1px solid var(--border-primary);
+}
+
+.chart-card__title {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-semibold);
+ color: var(--text-primary);
+}
+
+.chart-card__icon {
+ width: 18px;
+ height: 18px;
+ color: var(--color-primary-500);
+}
+
+.chart-card__actions {
+ display: flex;
+ gap: var(--space-1);
+}
+
+.chart-card__action {
+ padding: var(--space-1-5);
+ border-radius: var(--radius-default);
+ color: var(--text-tertiary);
+ transition: all var(--transition-fast);
+ opacity: 0.6;
+}
+
+.chart-card__action:hover {
+ color: var(--text-primary);
+ background-color: var(--bg-surface-hover);
+ opacity: 1;
+}
+
+.chart-card:hover .chart-card__action {
+ opacity: 0.8;
+}
+
+.chart-card__action i {
+ width: 14px;
+ height: 14px;
+}
+
+.chart-card__content {
+ position: relative;
+ padding: var(--space-4);
+ min-height: 450px;
+}
+
+.chart-card__plot {
+ width: 100% !important;
+ height: 420px !important;
+ min-height: 400px;
+ position: relative;
+}
+
+.chart-card__plot .js-plotly-plot {
+ width: 100% !important;
+ height: 100% !important;
+}
+
+.chart-card__plot .plotly .svg-container {
+ width: 100% !important;
+ height: 100% !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-top: 1px solid var(--border-primary);
+ font-size: var(--font-size-xs);
+ color: var(--text-tertiary);
+}
+
+.chart-card__timestamp {
+ font-family: var(--font-mono);
+}
+
+.chart-card__sweep {
+ font-family: var(--font-mono);
+ font-weight: var(--font-weight-medium);
+}
+
+/* Notifications */
+.notifications {
+ position: fixed;
+ top: var(--space-4);
+ right: var(--space-4);
+ z-index: var(--z-toast);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ pointer-events: none;
+}
+
+.notification {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ padding: var(--space-4) var(--space-5);
+ background-color: var(--bg-surface);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-lg);
+ min-width: 320px;
+ max-width: 480px;
+ pointer-events: auto;
+ animation: slideInRight 0.3s ease;
+}
+
+.notification--success {
+ border-left: 4px solid var(--color-success-500);
+}
+
+.notification--error {
+ border-left: 4px solid var(--color-error-500);
+}
+
+.notification--warning {
+ border-left: 4px solid var(--color-warning-500);
+}
+
+.notification--info {
+ border-left: 4px solid var(--color-primary-500);
+}
+
+.notification__icon {
+ width: 20px;
+ height: 20px;
+ margin-top: var(--space-0-5);
+ flex-shrink: 0;
+}
+
+.notification--success .notification__icon {
+ color: var(--color-success-500);
+}
+
+.notification--error .notification__icon {
+ color: var(--color-error-500);
+}
+
+.notification--warning .notification__icon {
+ color: var(--color-warning-500);
+}
+
+.notification--info .notification__icon {
+ color: var(--color-primary-500);
+}
+
+.notification__content {
+ flex: 1;
+ min-width: 0;
+}
+
+.notification__title {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ color: var(--text-primary);
+ margin-bottom: var(--space-1);
+}
+
+.notification__message {
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+ line-height: var(--line-height-relaxed);
+}
+
+.notification__close {
+ padding: var(--space-1);
+ border-radius: var(--radius-default);
+ color: var(--text-tertiary);
+ transition: all var(--transition-fast);
+ margin: calc(-1 * var(--space-1)) calc(-1 * var(--space-2)) calc(-1 * var(--space-1)) 0;
+}
+
+.notification__close:hover {
+ color: var(--text-primary);
+ background-color: var(--bg-surface-hover);
+}
+
+.notification__close i {
+ width: 16px;
+ height: 16px;
+}
+
+@keyframes slideInRight {
+ from {
+ opacity: 0;
+ transform: translateX(100px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+/* Form Elements (for future use) */
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.form-label {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ color: var(--text-primary);
+}
+
+.form-input {
+ padding: var(--space-2-5) var(--space-3);
+ background-color: var(--bg-primary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-lg);
+ font-size: var(--font-size-sm);
+ color: var(--text-primary);
+ transition: all var(--transition-fast);
+}
+
+.form-input:focus {
+ outline: none;
+ border-color: var(--color-primary-500);
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.form-input::placeholder {
+ color: var(--text-tertiary);
+}
\ No newline at end of file
diff --git a/vna_system/web_ui/static/css/layout.css b/vna_system/web_ui/static/css/layout.css
new file mode 100644
index 0000000..1c25a0d
--- /dev/null
+++ b/vna_system/web_ui/static/css/layout.css
@@ -0,0 +1,377 @@
+/* Base Layout */
+html {
+ font-size: 16px;
+}
+
+body {
+ font-family: var(--font-family);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-normal);
+ line-height: var(--line-height-normal);
+ color: var(--text-primary);
+ background-color: var(--bg-primary);
+ overflow-x: hidden;
+}
+
+/* Header */
+.header {
+ position: sticky;
+ top: 0;
+ height: var(--header-height);
+ background-color: var(--bg-surface);
+ border-bottom: 1px solid var(--border-primary);
+ backdrop-filter: blur(8px);
+ z-index: var(--z-dropdown);
+}
+
+.header__container {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 100%;
+ padding: 0 var(--space-6);
+ max-width: var(--max-content-width);
+ margin: 0 auto;
+}
+
+.header__brand {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.header__icon {
+ width: 24px;
+ height: 24px;
+ color: var(--color-primary-500);
+}
+
+.header__title {
+ font-size: var(--font-size-xl);
+ font-weight: var(--font-weight-semibold);
+ color: var(--text-primary);
+}
+
+.header__subtitle {
+ font-size: var(--font-size-sm);
+ color: var(--text-tertiary);
+ padding: var(--space-1) var(--space-2);
+ background-color: var(--bg-tertiary);
+ border-radius: var(--radius-default);
+}
+
+.header__status {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-6);
+}
+
+.header__stats {
+ display: flex;
+ gap: var(--space-4);
+}
+
+.stat-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ background-color: var(--bg-tertiary);
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border-primary);
+}
+
+.stat-label {
+ font-size: var(--font-size-xs);
+ color: var(--text-tertiary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-weight: var(--font-weight-medium);
+}
+
+.stat-value {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+}
+
+.header__nav {
+ display: flex;
+ gap: var(--space-1);
+}
+
+/* Navigation */
+.nav-btn {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ border-radius: var(--radius-lg);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ color: var(--text-secondary);
+ background-color: transparent;
+ transition: all var(--transition-fast);
+ cursor: pointer;
+}
+
+.nav-btn:hover {
+ color: var(--text-primary);
+ background-color: var(--bg-surface-hover);
+}
+
+.nav-btn--active {
+ color: var(--color-primary-400);
+ background-color: var(--color-primary-900);
+}
+
+.nav-btn i {
+ width: 16px;
+ height: 16px;
+}
+
+/* Status Indicator */
+.status-indicator {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.status-indicator__dot {
+ width: 8px;
+ height: 8px;
+ border-radius: var(--radius-full);
+ background-color: var(--color-error-500);
+ animation: pulse 2s infinite;
+}
+
+.status-indicator--connected .status-indicator__dot {
+ background-color: var(--color-success-500);
+ animation: none;
+}
+
+.status-indicator--connecting .status-indicator__dot {
+ background-color: var(--color-warning-500);
+}
+
+.status-indicator__text {
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+ font-weight: var(--font-weight-medium);
+}
+
+@keyframes pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+/* Main Content */
+.main {
+ min-height: calc(100vh - var(--header-height));
+ max-width: var(--max-content-width);
+ margin: 0 auto;
+ padding: var(--space-6);
+}
+
+/* Views */
+.view {
+ display: none;
+ animation: fadeIn 0.3s ease;
+}
+
+.view--active {
+ display: block;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* Controls Panel */
+.controls-panel {
+ margin-bottom: var(--space-6);
+ background-color: var(--bg-surface);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-xl);
+ overflow: hidden;
+}
+
+.controls-panel__container {
+ display: flex;
+ gap: var(--space-6);
+ padding: var(--space-4) var(--space-6);
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.controls-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ min-width: 0;
+}
+
+.controls-label {
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ color: var(--text-tertiary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+/* Charts Container */
+.charts-container {
+ position: relative;
+ min-height: 60vh;
+}
+
+.charts-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(550px, 1fr));
+ gap: var(--space-6);
+ margin-bottom: var(--space-6);
+}
+
+/* Empty State */
+.empty-state {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 60vh;
+ text-align: center;
+}
+
+.empty-state--hidden {
+ display: none;
+}
+
+.empty-state__content {
+ max-width: 400px;
+}
+
+.empty-state__icon {
+ width: 64px;
+ height: 64px;
+ margin: 0 auto var(--space-6);
+ color: var(--color-primary-500);
+ opacity: 0.5;
+}
+
+.empty-state__title {
+ font-size: var(--font-size-2xl);
+ font-weight: var(--font-weight-semibold);
+ color: var(--text-primary);
+ margin-bottom: var(--space-2);
+}
+
+.empty-state__description {
+ font-size: var(--font-size-base);
+ color: var(--text-secondary);
+ margin-bottom: var(--space-6);
+ line-height: var(--line-height-relaxed);
+}
+
+/* Loading Spinner */
+.loading-spinner {
+ display: flex;
+ justify-content: center;
+}
+
+.spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid var(--border-primary);
+ border-top: 3px solid var(--color-primary-500);
+ border-radius: var(--radius-full);
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+/* Settings & Logs Views */
+.settings-container,
+.logs-container {
+ max-width: 800px;
+ margin: 0 auto;
+ text-align: center;
+ padding: var(--space-12);
+}
+
+.settings-container h2,
+.logs-container h2 {
+ font-size: var(--font-size-3xl);
+ font-weight: var(--font-weight-semibold);
+ color: var(--text-primary);
+ margin-bottom: var(--space-4);
+}
+
+.settings-container p,
+.logs-container p {
+ font-size: var(--font-size-lg);
+ color: var(--text-secondary);
+}
+
+/* Responsive Design */
+@media (max-width: 1024px) {
+ .charts-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .controls-panel__container {
+ flex-direction: column;
+ align-items: stretch;
+ gap: var(--space-4);
+ }
+}
+
+@media (max-width: 768px) {
+ .header__container {
+ padding: 0 var(--space-4);
+ }
+
+ .header__brand {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--space-1);
+ }
+
+ .header__nav {
+ flex-direction: column;
+ }
+
+ .main {
+ padding: var(--space-4);
+ }
+
+ .nav-btn span {
+ display: none;
+ }
+}
+
+@media (max-width: 480px) {
+ .header__status {
+ display: none;
+ }
+
+ .charts-grid {
+ grid-template-columns: 1fr;
+ gap: var(--space-4);
+ }
+}
\ No newline at end of file
diff --git a/vna_system/web_ui/static/css/normalize.css b/vna_system/web_ui/static/css/normalize.css
new file mode 100644
index 0000000..d724c71
--- /dev/null
+++ b/vna_system/web_ui/static/css/normalize.css
@@ -0,0 +1,54 @@
+/* Modern CSS Reset */
+*, *::before, *::after {
+ box-sizing: border-box;
+}
+
+* {
+ margin: 0;
+}
+
+html, body {
+ height: 100%;
+}
+
+body {
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+
+img, picture, video, canvas, svg {
+ display: block;
+ max-width: 100%;
+}
+
+input, button, textarea, select {
+ font: inherit;
+}
+
+p, h1, h2, h3, h4, h5, h6 {
+ overflow-wrap: break-word;
+}
+
+#root, #__next {
+ isolation: isolate;
+}
+
+/* Remove default button styles */
+button {
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+}
+
+/* Remove default list styles */
+ul, ol {
+ list-style: none;
+ padding: 0;
+}
+
+/* Remove default link styles */
+a {
+ color: inherit;
+ text-decoration: none;
+}
\ No newline at end of file
diff --git a/vna_system/web_ui/static/css/variables.css b/vna_system/web_ui/static/css/variables.css
new file mode 100644
index 0000000..e2cedbf
--- /dev/null
+++ b/vna_system/web_ui/static/css/variables.css
@@ -0,0 +1,134 @@
+:root {
+ /* Colors - Modern dark theme with blue accents */
+ --color-primary-50: #eff6ff;
+ --color-primary-100: #dbeafe;
+ --color-primary-200: #bfdbfe;
+ --color-primary-300: #93c5fd;
+ --color-primary-400: #60a5fa;
+ --color-primary-500: #3b82f6;
+ --color-primary-600: #2563eb;
+ --color-primary-700: #1d4ed8;
+ --color-primary-800: #1e40af;
+ --color-primary-900: #1e3a8a;
+
+ --color-gray-50: #f8fafc;
+ --color-gray-100: #f1f5f9;
+ --color-gray-200: #e2e8f0;
+ --color-gray-300: #cbd5e1;
+ --color-gray-400: #94a3b8;
+ --color-gray-500: #64748b;
+ --color-gray-600: #475569;
+ --color-gray-700: #334155;
+ --color-gray-800: #1e293b;
+ --color-gray-900: #0f172a;
+
+ --color-success-400: #4ade80;
+ --color-success-500: #22c55e;
+ --color-success-600: #16a34a;
+
+ --color-warning-400: #facc15;
+ --color-warning-500: #eab308;
+ --color-warning-600: #ca8a04;
+
+ --color-error-400: #f87171;
+ --color-error-500: #ef4444;
+ --color-error-600: #dc2626;
+
+ /* Dark Theme Colors */
+ --bg-primary: var(--color-gray-900);
+ --bg-secondary: var(--color-gray-800);
+ --bg-tertiary: var(--color-gray-700);
+ --bg-surface: var(--color-gray-800);
+ --bg-surface-hover: var(--color-gray-700);
+ --bg-overlay: rgba(15, 23, 42, 0.95);
+
+ --text-primary: var(--color-gray-50);
+ --text-secondary: var(--color-gray-300);
+ --text-tertiary: var(--color-gray-400);
+
+ --border-primary: var(--color-gray-700);
+ --border-secondary: var(--color-gray-600);
+
+ /* Typography */
+ --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
+ --font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+
+ --font-size-xs: 0.75rem; /* 12px */
+ --font-size-sm: 0.875rem; /* 14px */
+ --font-size-base: 1rem; /* 16px */
+ --font-size-lg: 1.125rem; /* 18px */
+ --font-size-xl: 1.25rem; /* 20px */
+ --font-size-2xl: 1.5rem; /* 24px */
+ --font-size-3xl: 1.875rem; /* 30px */
+
+ --font-weight-light: 300;
+ --font-weight-normal: 400;
+ --font-weight-medium: 500;
+ --font-weight-semibold: 600;
+ --font-weight-bold: 700;
+
+ --line-height-tight: 1.25;
+ --line-height-normal: 1.5;
+ --line-height-relaxed: 1.75;
+
+ /* Spacing */
+ --space-px: 1px;
+ --space-0-5: 0.125rem;
+ --space-1: 0.25rem;
+ --space-1-5: 0.375rem;
+ --space-2: 0.5rem;
+ --space-2-5: 0.625rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --space-12: 3rem;
+ --space-16: 4rem;
+ --space-20: 5rem;
+ --space-24: 6rem;
+
+ /* Border Radius */
+ --radius-none: 0px;
+ --radius-sm: 0.125rem;
+ --radius-default: 0.25rem;
+ --radius-md: 0.375rem;
+ --radius-lg: 0.5rem;
+ --radius-xl: 0.75rem;
+ --radius-2xl: 1rem;
+ --radius-full: 9999px;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ --shadow-default: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
+ --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
+ --shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+
+ /* Transitions */
+ --transition-fast: 0.15s ease;
+ --transition-normal: 0.2s ease;
+ --transition-slow: 0.3s ease;
+
+ /* Z-index */
+ --z-dropdown: 10;
+ --z-modal: 50;
+ --z-toast: 100;
+
+ /* Layout */
+ --header-height: 4rem;
+ --sidebar-width: 16rem;
+ --max-content-width: 1400px;
+
+ /* Chart Colors */
+ --chart-color-1: var(--color-primary-500);
+ --chart-color-2: var(--color-success-500);
+ --chart-color-3: var(--color-warning-500);
+ --chart-color-4: var(--color-error-500);
+ --chart-color-5: #8b5cf6;
+ --chart-color-6: #06b6d4;
+ --chart-color-7: #f59e0b;
+ --chart-color-8: #10b981;
+}
\ No newline at end of file
diff --git a/vna_system/web_ui/static/js/main.js b/vna_system/web_ui/static/js/main.js
new file mode 100644
index 0000000..1e8996f
--- /dev/null
+++ b/vna_system/web_ui/static/js/main.js
@@ -0,0 +1,394 @@
+/**
+ * VNA System Web UI - Main Entry Point
+ * Modern ES6+ JavaScript with modular architecture
+ */
+
+import { WebSocketManager } from './modules/websocket.js';
+import { ChartManager } from './modules/charts.js';
+import { UIManager } from './modules/ui.js';
+import { NotificationManager } from './modules/notifications.js';
+import { StorageManager } from './modules/storage.js';
+
+/**
+ * Main Application Class
+ * Coordinates all modules and manages application lifecycle
+ */
+class VNADashboard {
+ constructor() {
+ this.isInitialized = false;
+ this.config = {
+ websocket: {
+ url: this.getWebSocketURL(),
+ reconnectInterval: 3000,
+ maxReconnectAttempts: 10
+ },
+ charts: {
+ theme: 'dark',
+ updateInterval: 100,
+ maxDataPoints: 1000,
+ animation: true
+ },
+ ui: {
+ autoHideNotifications: true,
+ notificationTimeout: 5000
+ }
+ };
+
+ // Initialize managers
+ this.storage = new StorageManager();
+ this.notifications = new NotificationManager();
+ this.ui = new UIManager(this.notifications);
+ this.charts = new ChartManager(this.config.charts, this.notifications);
+ this.websocket = new WebSocketManager(this.config.websocket, this.notifications);
+
+ // Bind methods
+ this.handleWebSocketData = this.handleWebSocketData.bind(this);
+ this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
+ this.handleBeforeUnload = this.handleBeforeUnload.bind(this);
+ }
+
+ /**
+ * Initialize the application
+ */
+ async init() {
+ try {
+ console.log('🚀 Initializing VNA Dashboard...');
+
+ // Initialize Lucide icons
+ if (typeof lucide !== 'undefined') {
+ lucide.createIcons();
+ }
+
+ // Initialize modules in correct order
+ await this.initializeModules();
+
+ // Set up event listeners
+ this.setupEventListeners();
+
+ // Connect WebSocket
+ await this.websocket.connect();
+
+ this.isInitialized = true;
+ console.log('✅ VNA Dashboard initialized successfully');
+
+ // Show welcome notification
+ this.notifications.show({
+ type: 'info',
+ title: 'Dashboard Ready',
+ message: 'Connected to VNA System. Waiting for sweep data...'
+ });
+
+ } catch (error) {
+ console.error('❌ Failed to initialize VNA Dashboard:', error);
+ this.notifications.show({
+ type: 'error',
+ title: 'Initialization Failed',
+ message: error.message || 'Failed to initialize dashboard'
+ });
+ }
+ }
+
+ /**
+ * Initialize all modules
+ */
+ async initializeModules() {
+ // Initialize UI manager first
+ await this.ui.init();
+
+ // Initialize chart manager
+ await this.charts.init();
+
+ // Set up UI event handlers
+ this.setupUIHandlers();
+
+ // 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();
+ }
+ }
+
+ /**
+ * Set up event listeners
+ */
+ setupEventListeners() {
+ // WebSocket events
+ 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);
+ window.addEventListener('beforeunload', this.handleBeforeUnload);
+
+ // Keyboard shortcuts
+ document.addEventListener('keydown', this.handleKeyboardShortcuts.bind(this));
+ }
+
+ /**
+ * Set up UI-specific event handlers
+ */
+ setupUIHandlers() {
+ // Navigation
+ this.ui.onViewChange((view) => {
+ console.log(`📱 Switched to view: ${view}`);
+ });
+
+ // Processor toggles
+ 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
+
+ this.ui.onExportData(() => {
+ console.log('📊 Exporting chart data...');
+ this.exportData();
+ });
+ }
+
+ /**
+ * Handle 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'
+ });
+ }
+ }
+
+ /**
+ * Handle visibility changes (tab switch, minimize, etc.)
+ */
+ handleVisibilityChange() {
+ if (document.hidden) {
+ // Pause expensive operations when hidden
+ this.charts.pause();
+ } else {
+ // Resume when visible
+ this.charts.resume();
+ }
+ }
+
+ /**
+ * Handle before unload (page close/refresh)
+ */
+ handleBeforeUnload() {
+ this.savePreferences();
+ this.websocket.disconnect();
+ }
+
+ /**
+ * Handle keyboard shortcuts
+ */
+ handleKeyboardShortcuts(event) {
+ // Ctrl/Cmd + E: Export data
+ if ((event.ctrlKey || event.metaKey) && event.key === 'e') {
+ event.preventDefault();
+ this.ui.triggerExportData();
+ }
+
+ // Ctrl/Cmd + Shift + R: Reconnect WebSocket
+ if ((event.ctrlKey || event.metaKey) && event.key === 'r' && event.shiftKey) {
+ event.preventDefault();
+ this.websocket.reconnect();
+ }
+ }
+
+ /**
+ * Export data functionality
+ */
+ exportData() {
+ try {
+ const data = this.charts.exportData();
+ const blob = new Blob([JSON.stringify(data, null, 2)], {
+ type: 'application/json'
+ });
+
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `vna-data-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ this.notifications.show({
+ type: 'success',
+ title: 'Export Complete',
+ message: 'Chart data exported successfully'
+ });
+ } catch (error) {
+ console.error('❌ Export failed:', error);
+ this.notifications.show({
+ type: 'error',
+ title: 'Export Failed',
+ message: 'Failed to export chart data'
+ });
+ }
+ }
+
+ /**
+ * 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;
+ }
+ }
+ }
+
+ /**
+ * Apply user preferences
+ */
+ applyPreferences(preferences) {
+ try {
+ if (preferences.disabledProcessors && Array.isArray(preferences.disabledProcessors)) {
+ preferences.disabledProcessors.forEach(processor => {
+ if (this.ui && typeof this.ui.setProcessorEnabled === 'function') {
+ this.ui.setProcessorEnabled(processor, false);
+ }
+ });
+ }
+
+ if (preferences.theme) {
+ if (this.ui && typeof this.ui.setTheme === 'function') {
+ this.ui.setTheme(preferences.theme);
+ }
+ }
+ } catch (error) {
+ console.warn('⚠️ Error applying preferences:', error);
+ }
+ }
+
+ /**
+ * Save current preferences
+ */
+ async savePreferences() {
+ try {
+ const preferences = {
+ disabledProcessors: this.charts.getDisabledProcessors(),
+ theme: this.ui.getCurrentTheme(),
+ timestamp: Date.now()
+ };
+ await this.storage.savePreferences(preferences);
+ } catch (error) {
+ console.warn('⚠️ Failed to save preferences:', error);
+ }
+ }
+
+ /**
+ * Get WebSocket URL based on current location
+ */
+ getWebSocketURL() {
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const host = location.host;
+ return `${protocol}//${host}/ws/processing`;
+ }
+
+ /**
+ * Cleanup resources
+ */
+ destroy() {
+ if (!this.isInitialized) return;
+
+ 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.ui.destroy();
+ this.notifications.destroy();
+
+ this.isInitialized = false;
+ console.log('✅ VNA Dashboard cleanup complete');
+ }
+}
+
+// Initialize dashboard when DOM is ready
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => {
+ window.vnaDashboard = new VNADashboard();
+ window.vnaDashboard.init();
+ });
+} else {
+ window.vnaDashboard = new VNADashboard();
+ window.vnaDashboard.init();
+}
+
+// Global error handling
+window.addEventListener('error', (event) => {
+ console.error('🚨 Global error:', event.error);
+});
+
+window.addEventListener('unhandledrejection', (event) => {
+ console.error('🚨 Unhandled promise rejection:', event.reason);
+});
+
+// Development helpers
+if (typeof process !== 'undefined' && process?.env?.NODE_ENV === 'development') {
+ window.debug = {
+ dashboard: () => window.vnaDashboard,
+ websocket: () => window.vnaDashboard?.websocket,
+ charts: () => window.vnaDashboard?.charts,
+ ui: () => window.vnaDashboard?.ui
+ };
+} else {
+ // Browser environment debug helpers
+ window.debug = {
+ dashboard: () => window.vnaDashboard,
+ websocket: () => window.vnaDashboard?.websocket,
+ charts: () => window.vnaDashboard?.charts,
+ ui: () => window.vnaDashboard?.ui
+ };
+}
\ No newline at end of file
diff --git a/vna_system/web_ui/static/js/modules/charts.js b/vna_system/web_ui/static/js/modules/charts.js
new file mode 100644
index 0000000..bb874a4
--- /dev/null
+++ b/vna_system/web_ui/static/js/modules/charts.js
@@ -0,0 +1,693 @@
+/**
+ * Chart Manager
+ * Handles Plotly.js chart creation, updates, and management
+ */
+
+export class ChartManager {
+ constructor(config, notifications) {
+ this.config = config;
+ this.notifications = notifications;
+
+ // Chart storage
+ this.charts = new Map(); // processor_name -> chart instance
+ this.chartData = new Map(); // processor_name -> data array
+ this.disabledProcessors = new Set();
+
+ // UI elements
+ this.chartsGrid = null;
+ this.emptyState = null;
+
+ // Update management
+ this.updateQueue = new Map();
+ this.isUpdating = false;
+ this.isPaused = false;
+
+ // Performance tracking
+ this.performanceStats = {
+ chartsCreated: 0,
+ updatesProcessed: 0,
+ avgUpdateTime: 0,
+ lastUpdateTime: null
+ };
+
+ // Plotly theme configuration
+ this.plotlyConfig = {
+ displayModeBar: true,
+ modeBarButtonsToRemove: [
+ 'select2d', 'lasso2d', 'hoverClosestCartesian',
+ 'hoverCompareCartesian', 'toggleSpikelines'
+ ],
+ displaylogo: false,
+ responsive: false, // Disable responsive to avoid resize issues
+ doubleClick: 'reset',
+ toImageButtonOptions: {
+ format: 'png',
+ filename: 'vna_chart',
+ height: 600,
+ width: 800,
+ scale: 1
+ }
+ };
+
+ this.plotlyLayout = {
+ plot_bgcolor: 'transparent',
+ paper_bgcolor: 'transparent',
+ font: {
+ family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif',
+ size: 12,
+ color: '#f1f5f9' // --text-secondary
+ },
+ colorway: [
+ '#3b82f6', '#22c55e', '#eab308', '#ef4444',
+ '#8b5cf6', '#06b6d4', '#f59e0b', '#10b981'
+ ],
+ margin: { l: 60, r: 40, t: 40, b: 60 },
+ showlegend: false,
+ xaxis: {
+ gridcolor: '#334155',
+ zerolinecolor: '#475569',
+ color: '#cbd5e1',
+ fixedrange: false // Allow zoom/pan
+ },
+ yaxis: {
+ gridcolor: '#334155',
+ zerolinecolor: '#475569',
+ color: '#cbd5e1',
+ fixedrange: false // Allow zoom/pan
+ },
+ autosize: true,
+ width: null, // Let CSS control width
+ height: null // Let CSS control height
+ };
+ }
+
+ /**
+ * Initialize chart manager
+ */
+ async init() {
+ console.log('📊 Initializing Chart Manager...');
+
+ // Get DOM elements
+ this.chartsGrid = document.getElementById('chartsGrid');
+ this.emptyState = document.getElementById('emptyState');
+
+ if (!this.chartsGrid || !this.emptyState) {
+ throw new Error('Required DOM elements not found');
+ }
+
+ // Ensure Plotly is available
+ if (typeof Plotly === 'undefined') {
+ throw new Error('Plotly.js not loaded');
+ }
+
+ console.log('✅ Chart Manager initialized');
+ }
+
+ /**
+ * Add new data from WebSocket
+ */
+ addData(processingResult) {
+ try {
+ const { processor_name, sweep_number, plotly_figure, data } = processingResult;
+
+ if (!processor_name || !plotly_figure) {
+ console.warn('⚠️ Invalid processing result:', processingResult);
+ return;
+ }
+
+ // Check if processor is disabled
+ if (this.disabledProcessors.has(processor_name)) {
+ console.log(`⏸️ Skipping disabled processor: ${processor_name}`);
+ return;
+ }
+
+ // Store data
+ if (!this.chartData.has(processor_name)) {
+ this.chartData.set(processor_name, []);
+ }
+
+ const dataArray = this.chartData.get(processor_name);
+ dataArray.push({
+ sweep_number,
+ plotly_figure,
+ data,
+ timestamp: new Date()
+ });
+
+ // Limit data points for performance
+ if (dataArray.length > this.config.maxDataPoints) {
+ dataArray.splice(0, dataArray.length - this.config.maxDataPoints);
+ }
+
+ // Create or update chart
+ if (!this.charts.has(processor_name)) {
+ this.createChart(processor_name);
+ }
+
+ this.updateChart(processor_name, plotly_figure);
+ this.hideEmptyState();
+
+ } catch (error) {
+ console.error('❌ Error adding chart data:', error);
+ this.notifications.show({
+ type: 'error',
+ title: 'Chart Error',
+ message: `Failed to update ${processingResult?.processor_name} chart`
+ });
+ }
+ }
+
+ /**
+ * Create new chart
+ */
+ createChart(processorName) {
+ console.log(`📊 Creating chart for processor: ${processorName}`);
+
+ // Create chart card HTML
+ const chartCard = this.createChartCard(processorName);
+ this.chartsGrid.appendChild(chartCard);
+
+ // Get plot container
+ const plotContainer = chartCard.querySelector('.chart-card__plot');
+
+ // Create empty chart with proper sizing
+ const layout = {
+ ...this.plotlyLayout,
+ title: {
+ text: this.formatProcessorName(processorName),
+ font: { size: 16, color: '#f1f5f9' }
+ },
+ width: plotContainer.clientWidth || 500,
+ height: plotContainer.clientHeight || 420
+ };
+
+ Plotly.newPlot(plotContainer, [], layout, this.plotlyConfig);
+
+ // Set up resize observer for proper sizing
+ if (window.ResizeObserver) {
+ const resizeObserver = new ResizeObserver(() => {
+ if (plotContainer && plotContainer.clientWidth > 0) {
+ Plotly.Plots.resize(plotContainer);
+ }
+ });
+ resizeObserver.observe(plotContainer);
+
+ // Store observer for cleanup
+ plotContainer._resizeObserver = resizeObserver;
+ }
+
+ // Store chart reference
+ this.charts.set(processorName, {
+ element: chartCard,
+ plotContainer: plotContainer,
+ isVisible: true
+ });
+
+ this.performanceStats.chartsCreated++;
+
+ // Add animation class
+ if (this.config.animation) {
+ setTimeout(() => {
+ chartCard.classList.add('chart-card--animated');
+ }, 50);
+ }
+ }
+
+ /**
+ * Update existing chart
+ */
+ updateChart(processorName, plotlyFigure) {
+ if (this.isPaused) return;
+
+ const chart = this.charts.get(processorName);
+ if (!chart || !chart.plotContainer) {
+ console.warn(`⚠️ Chart not found for processor: ${processorName}`);
+ return;
+ }
+
+ try {
+ const startTime = performance.now();
+
+ // Queue update to avoid blocking UI
+ this.queueUpdate(processorName, () => {
+ // Prepare layout without overriding size
+ const updateLayout = {
+ ...this.plotlyLayout,
+ ...plotlyFigure.layout,
+ title: {
+ text: this.formatProcessorName(processorName),
+ font: { size: 16, color: '#f1f5f9' }
+ }
+ };
+
+ // Don't override existing width/height if they're working
+ delete updateLayout.width;
+ delete updateLayout.height;
+
+ // Update plot with new data
+ Plotly.react(
+ chart.plotContainer,
+ plotlyFigure.data,
+ updateLayout,
+ this.plotlyConfig
+ );
+
+ // Update metadata
+ this.updateChartMetadata(processorName);
+
+ // Update performance stats
+ const updateTime = performance.now() - startTime;
+ this.updatePerformanceStats(updateTime);
+ });
+
+ } catch (error) {
+ console.error(`❌ Error updating chart ${processorName}:`, error);
+ }
+ }
+
+ /**
+ * Queue chart update to avoid blocking UI
+ */
+ queueUpdate(processorName, updateFn) {
+ this.updateQueue.set(processorName, updateFn);
+
+ if (!this.isUpdating) {
+ this.processUpdateQueue();
+ }
+ }
+
+ /**
+ * Process queued updates
+ */
+ async processUpdateQueue() {
+ if (this.isPaused) return;
+
+ this.isUpdating = true;
+
+ while (this.updateQueue.size > 0 && !this.isPaused) {
+ const [processorName, updateFn] = this.updateQueue.entries().next().value;
+ this.updateQueue.delete(processorName);
+
+ try {
+ await updateFn();
+ } catch (error) {
+ console.error(`❌ Error in queued update for ${processorName}:`, error);
+ }
+
+ // Yield to browser between updates
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+
+ this.isUpdating = false;
+ }
+
+ /**
+ * Create chart card HTML
+ */
+ createChartCard(processorName) {
+ const card = document.createElement('div');
+ card.className = 'chart-card';
+ card.dataset.processor = processorName;
+
+ card.innerHTML = `
+
+
+
+ `;
+
+ // Add event listeners
+ this.setupChartCardEvents(card, processorName);
+
+ // Initialize Lucide icons
+ if (typeof lucide !== 'undefined') {
+ lucide.createIcons({ attrs: { 'stroke-width': 1.5 } });
+ }
+
+ return card;
+ }
+
+ /**
+ * Set up chart card event listeners
+ */
+ setupChartCardEvents(card, processorName) {
+ card.addEventListener('click', (event) => {
+ const action = event.target.closest('[data-action]')?.dataset.action;
+ if (!action) return;
+
+ event.stopPropagation();
+
+ switch (action) {
+ case 'fullscreen':
+ this.toggleFullscreen(processorName);
+ break;
+ case 'download':
+ this.downloadChart(processorName);
+ break;
+ case 'hide':
+ this.hideChart(processorName);
+ // Also disable the processor toggle
+ if (window.vnaDashboard && window.vnaDashboard.ui) {
+ window.vnaDashboard.ui.setProcessorEnabled(processorName, false);
+ }
+ break;
+ }
+ });
+ }
+
+ /**
+ * Update chart metadata
+ */
+ updateChartMetadata(processorName) {
+ const chart = this.charts.get(processorName);
+ const data = this.chartData.get(processorName);
+
+ if (!chart || !data || data.length === 0) return;
+
+ const latestData = data[data.length - 1];
+ const timestampEl = chart.element.querySelector('[data-timestamp]');
+ const sweepEl = chart.element.querySelector('[data-sweep]');
+
+ if (timestampEl) {
+ timestampEl.textContent = `Last update: ${latestData.timestamp.toLocaleTimeString()}`;
+ timestampEl.dataset.timestamp = latestData.timestamp.toISOString();
+ }
+
+ if (sweepEl) {
+ sweepEl.textContent = `Sweep: #${latestData.sweep_number}`;
+ sweepEl.dataset.sweep = latestData.sweep_number;
+ }
+ }
+
+ /**
+ * Format processor name for display
+ */
+ formatProcessorName(processorName) {
+ return processorName
+ .split('_')
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ');
+ }
+
+ /**
+ * Toggle processor visibility
+ */
+ toggleProcessor(processorName, enabled) {
+ if (enabled) {
+ this.disabledProcessors.delete(processorName);
+ this.showChart(processorName);
+ } else {
+ this.disabledProcessors.add(processorName);
+ this.hideChart(processorName);
+ }
+ }
+
+ /**
+ * Show chart
+ */
+ showChart(processorName) {
+ const chart = this.charts.get(processorName);
+ if (chart) {
+ chart.element.classList.remove('chart-card--hidden');
+ chart.isVisible = true;
+
+ // Trigger resize to ensure proper rendering
+ setTimeout(() => {
+ if (chart.plotContainer) {
+ Plotly.Plots.resize(chart.plotContainer);
+ }
+ }, 100);
+ }
+
+ this.updateEmptyStateVisibility();
+ }
+
+ /**
+ * Hide chart
+ */
+ hideChart(processorName) {
+ const chart = this.charts.get(processorName);
+ if (chart) {
+ chart.element.classList.add('chart-card--hidden');
+ chart.isVisible = false;
+ }
+
+ this.updateEmptyStateVisibility();
+ }
+
+ /**
+ * Remove chart completely
+ */
+ removeChart(processorName) {
+ const chart = this.charts.get(processorName);
+ if (chart) {
+ // Clean up ResizeObserver
+ if (chart.plotContainer && chart.plotContainer._resizeObserver) {
+ chart.plotContainer._resizeObserver.disconnect();
+ chart.plotContainer._resizeObserver = null;
+ }
+
+ // Clean up Plotly
+ if (chart.plotContainer) {
+ Plotly.purge(chart.plotContainer);
+ }
+
+ // Remove from DOM
+ chart.element.remove();
+
+ // Clean up data
+ this.charts.delete(processorName);
+ this.chartData.delete(processorName);
+ this.disabledProcessors.delete(processorName);
+ }
+
+ this.updateEmptyStateVisibility();
+ }
+
+ /**
+ * Clear all charts
+ */
+ clearAll() {
+ console.log('🗑️ Clearing all charts...');
+
+ for (const [processorName] of this.charts) {
+ this.removeChart(processorName);
+ }
+
+ this.charts.clear();
+ this.chartData.clear();
+ this.updateQueue.clear();
+ this.updateEmptyStateVisibility();
+ }
+
+ /**
+ * Download chart as image
+ */
+ downloadChart(processorName) {
+ const chart = this.charts.get(processorName);
+ if (!chart || !chart.plotContainer) return;
+
+ try {
+ Plotly.downloadImage(chart.plotContainer, {
+ format: 'png',
+ width: 1200,
+ height: 800,
+ filename: `${processorName}-${Date.now()}`
+ });
+
+ this.notifications.show({
+ type: 'success',
+ title: 'Download Started',
+ message: `Chart image download started`
+ });
+
+ } catch (error) {
+ console.error('❌ Chart download failed:', error);
+ this.notifications.show({
+ type: 'error',
+ title: 'Download Failed',
+ message: 'Failed to download chart image'
+ });
+ }
+ }
+
+ /**
+ * Toggle fullscreen mode
+ */
+ toggleFullscreen(processorName) {
+ const chart = this.charts.get(processorName);
+ if (!chart || !chart.element) return;
+
+ if (!document.fullscreenElement) {
+ chart.element.requestFullscreen().then(() => {
+ // Resize chart for fullscreen after CSS transitions complete
+ setTimeout(() => {
+ if (chart.plotContainer) {
+ // Get fullscreen dimensions
+ const rect = chart.plotContainer.getBoundingClientRect();
+
+ // Update layout for fullscreen
+ Plotly.relayout(chart.plotContainer, {
+ width: rect.width,
+ height: rect.height
+ });
+
+ // Force resize
+ Plotly.Plots.resize(chart.plotContainer);
+ }
+ }, 200);
+ }).catch(console.error);
+ } else {
+ document.exitFullscreen().then(() => {
+ // Resize chart back to normal view
+ setTimeout(() => {
+ if (chart.plotContainer) {
+ Plotly.Plots.resize(chart.plotContainer);
+ }
+ }, 100);
+ });
+ }
+ }
+
+ /**
+ * Hide empty state
+ */
+ hideEmptyState() {
+ if (this.emptyState) {
+ this.emptyState.classList.add('empty-state--hidden');
+ }
+ }
+
+ /**
+ * Update empty state visibility
+ */
+ updateEmptyStateVisibility() {
+ if (!this.emptyState) return;
+
+ const hasVisibleCharts = Array.from(this.charts.values())
+ .some(chart => chart.isVisible);
+
+ if (hasVisibleCharts) {
+ this.emptyState.classList.add('empty-state--hidden');
+ } else {
+ this.emptyState.classList.remove('empty-state--hidden');
+ }
+ }
+
+ /**
+ * Update performance statistics
+ */
+ updatePerformanceStats(updateTime) {
+ this.performanceStats.updatesProcessed++;
+ this.performanceStats.lastUpdateTime = new Date();
+
+ // Calculate rolling average
+ const totalTime = this.performanceStats.avgUpdateTime *
+ (this.performanceStats.updatesProcessed - 1) + updateTime;
+ this.performanceStats.avgUpdateTime = totalTime / this.performanceStats.updatesProcessed;
+ }
+
+ /**
+ * Pause updates (for performance when tab is hidden)
+ */
+ pause() {
+ this.isPaused = true;
+ console.log('⏸️ Chart updates paused');
+ }
+
+ /**
+ * Resume updates
+ */
+ resume() {
+ this.isPaused = false;
+ console.log('▶️ Chart updates resumed');
+
+ // Process any queued updates
+ if (this.updateQueue.size > 0) {
+ this.processUpdateQueue();
+ }
+ }
+
+ /**
+ * Export all chart data
+ */
+ exportData() {
+ const exportData = {
+ timestamp: new Date().toISOString(),
+ charts: {},
+ stats: this.performanceStats
+ };
+
+ for (const [processorName, dataArray] of this.chartData) {
+ exportData.charts[processorName] = dataArray.map(item => ({
+ sweep_number: item.sweep_number,
+ data: item.data,
+ timestamp: item.timestamp.toISOString()
+ }));
+ }
+
+ return exportData;
+ }
+
+ /**
+ * Get disabled processors list
+ */
+ getDisabledProcessors() {
+ return Array.from(this.disabledProcessors);
+ }
+
+ /**
+ * Get chart statistics
+ */
+ getStats() {
+ return {
+ ...this.performanceStats,
+ totalCharts: this.charts.size,
+ visibleCharts: Array.from(this.charts.values()).filter(c => c.isVisible).length,
+ disabledProcessors: this.disabledProcessors.size,
+ queuedUpdates: this.updateQueue.size,
+ isPaused: this.isPaused
+ };
+ }
+
+ /**
+ * Cleanup
+ */
+ destroy() {
+ console.log('🧹 Cleaning up Chart Manager...');
+
+ // Clear all charts (this also cleans up Plotly instances)
+ this.clearAll();
+
+ // Clear update queue
+ this.updateQueue.clear();
+ this.isUpdating = false;
+ this.isPaused = true;
+
+ console.log('✅ Chart Manager cleanup complete');
+ }
+}
\ No newline at end of file
diff --git a/vna_system/web_ui/static/js/modules/notifications.js b/vna_system/web_ui/static/js/modules/notifications.js
new file mode 100644
index 0000000..baba19c
--- /dev/null
+++ b/vna_system/web_ui/static/js/modules/notifications.js
@@ -0,0 +1,466 @@
+/**
+ * Notification Manager
+ * Handles toast notifications and user feedback
+ */
+
+export class NotificationManager {
+ constructor() {
+ this.container = null;
+ this.notifications = new Map(); // id -> notification element
+ this.nextId = 1;
+
+ // Configuration
+ this.config = {
+ maxNotifications: 5,
+ defaultTimeout: 5000,
+ animationDuration: 300,
+ position: 'top-right' // top-right, top-left, bottom-right, bottom-left
+ };
+
+ // Notification types configuration
+ this.typeConfig = {
+ success: {
+ icon: 'check-circle',
+ timeout: 3000,
+ class: 'notification--success'
+ },
+ error: {
+ icon: 'alert-circle',
+ timeout: 7000,
+ class: 'notification--error'
+ },
+ warning: {
+ icon: 'alert-triangle',
+ timeout: 5000,
+ class: 'notification--warning'
+ },
+ info: {
+ icon: 'info',
+ timeout: 4000,
+ class: 'notification--info'
+ }
+ };
+
+ this.init();
+ }
+
+ /**
+ * Initialize notification system
+ */
+ init() {
+ // Create or find notification container
+ this.container = document.getElementById('notifications');
+
+ if (!this.container) {
+ this.container = document.createElement('div');
+ this.container.id = 'notifications';
+ this.container.className = 'notifications';
+ document.body.appendChild(this.container);
+ }
+
+ console.log('📢 Notification Manager initialized');
+ }
+
+ /**
+ * Show a notification
+ */
+ show(options) {
+ const notification = this.createNotification(options);
+ this.addNotification(notification);
+ return notification.id;
+ }
+
+ /**
+ * Create notification object
+ */
+ createNotification(options) {
+ const {
+ type = 'info',
+ title,
+ message,
+ timeout = null,
+ persistent = false,
+ actions = []
+ } = options;
+
+ const id = this.nextId++;
+ const config = this.typeConfig[type] || this.typeConfig.info;
+ const finalTimeout = persistent ? null : (timeout ?? config.timeout);
+
+ return {
+ id,
+ type,
+ title,
+ message,
+ timeout: finalTimeout,
+ persistent,
+ actions,
+ config,
+ element: null,
+ timer: null,
+ createdAt: new Date()
+ };
+ }
+
+ /**
+ * Add notification to DOM and manage queue
+ */
+ addNotification(notification) {
+ // Remove oldest notifications if we exceed the limit
+ this.enforceMaxNotifications();
+
+ // Create DOM element
+ notification.element = this.createNotificationElement(notification);
+
+ // Add to container
+ this.container.appendChild(notification.element);
+
+ // Store notification
+ this.notifications.set(notification.id, notification);
+
+ // Animate in
+ setTimeout(() => {
+ notification.element.classList.add('notification--visible');
+ }, 10);
+
+ // Set up auto-dismiss timer
+ if (notification.timeout) {
+ notification.timer = setTimeout(() => {
+ this.dismiss(notification.id);
+ }, notification.timeout);
+ }
+
+ // Initialize Lucide icons
+ if (typeof lucide !== 'undefined') {
+ lucide.createIcons({ attrs: { 'stroke-width': 1.5 } });
+ }
+
+ console.log(`📢 Showing ${notification.type} notification:`, notification.title);
+ }
+
+ /**
+ * Create notification DOM element
+ */
+ createNotificationElement(notification) {
+ const element = document.createElement('div');
+ element.className = `notification ${notification.config.class}`;
+ element.dataset.id = notification.id;
+
+ const iconHtml = ``;
+
+ const titleHtml = notification.title ?
+ `${this.escapeHtml(notification.title)}
` : '';
+
+ const messageHtml = notification.message ?
+ `${this.escapeHtml(notification.message)}
` : '';
+
+ const actionsHtml = notification.actions.length > 0 ?
+ this.createActionsHtml(notification.actions) : '';
+
+ const closeHtml = `
+
+ `;
+
+ element.innerHTML = `
+ ${iconHtml}
+
+ ${titleHtml}
+ ${messageHtml}
+ ${actionsHtml}
+
+ ${closeHtml}
+ `;
+
+ // Add event listeners
+ this.setupNotificationEvents(element, notification);
+
+ return element;
+ }
+
+ /**
+ * Create actions HTML for notification
+ */
+ createActionsHtml(actions) {
+ if (actions.length === 0) return '';
+
+ const actionsHtml = actions.map(action => {
+ const buttonClass = action.primary ? 'btn btn--primary btn--sm' : 'btn btn--ghost btn--sm';
+ return `
+
+ `;
+ }).join('');
+
+ return `${actionsHtml}
`;
+ }
+
+ /**
+ * Set up notification event listeners
+ */
+ setupNotificationEvents(element, notification) {
+ element.addEventListener('click', (event) => {
+ const action = event.target.closest('[data-action]')?.dataset.action;
+
+ if (action === 'close') {
+ this.dismiss(notification.id);
+ } else if (action === 'custom') {
+ const actionId = event.target.closest('[data-action-id]')?.dataset.actionId;
+ const actionConfig = notification.actions.find(a => a.id === actionId);
+
+ if (actionConfig && actionConfig.handler) {
+ try {
+ actionConfig.handler(notification);
+ } catch (error) {
+ console.error('❌ Error in notification action handler:', error);
+ }
+ }
+
+ // Auto-dismiss after action unless specified otherwise
+ if (!actionConfig || actionConfig.dismissOnClick !== false) {
+ this.dismiss(notification.id);
+ }
+ }
+ });
+
+ // Pause auto-dismiss on hover
+ element.addEventListener('mouseenter', () => {
+ if (notification.timer) {
+ clearTimeout(notification.timer);
+ notification.timer = null;
+ }
+ });
+
+ // Resume auto-dismiss on leave (if not persistent)
+ element.addEventListener('mouseleave', () => {
+ if (notification.timeout && !notification.persistent && !notification.timer) {
+ notification.timer = setTimeout(() => {
+ this.dismiss(notification.id);
+ }, 1000); // Shorter timeout after hover
+ }
+ });
+ }
+
+ /**
+ * Dismiss notification by ID
+ */
+ dismiss(id) {
+ const notification = this.notifications.get(id);
+ if (!notification) return;
+
+ console.log(`📢 Dismissing notification: ${id}`);
+
+ // Clear timer
+ if (notification.timer) {
+ clearTimeout(notification.timer);
+ notification.timer = null;
+ }
+
+ // Animate out
+ if (notification.element) {
+ notification.element.classList.add('notification--dismissing');
+
+ setTimeout(() => {
+ if (notification.element && notification.element.parentNode) {
+ notification.element.parentNode.removeChild(notification.element);
+ }
+ this.notifications.delete(id);
+ }, this.config.animationDuration);
+ } else {
+ this.notifications.delete(id);
+ }
+ }
+
+ /**
+ * Dismiss all notifications
+ */
+ dismissAll() {
+ console.log('📢 Dismissing all notifications');
+
+ for (const id of this.notifications.keys()) {
+ this.dismiss(id);
+ }
+ }
+
+ /**
+ * Dismiss notifications by type
+ */
+ dismissByType(type) {
+ console.log(`📢 Dismissing all ${type} notifications`);
+
+ for (const notification of this.notifications.values()) {
+ if (notification.type === type) {
+ this.dismiss(notification.id);
+ }
+ }
+ }
+
+ /**
+ * Update existing notification
+ */
+ update(id, updates) {
+ const notification = this.notifications.get(id);
+ if (!notification) return false;
+
+ // Update properties
+ Object.assign(notification, updates);
+
+ // Update DOM if needed
+ if (updates.title || updates.message) {
+ const titleEl = notification.element.querySelector('.notification__title');
+ const messageEl = notification.element.querySelector('.notification__message');
+
+ if (titleEl && updates.title !== undefined) {
+ titleEl.textContent = updates.title;
+ }
+
+ if (messageEl && updates.message !== undefined) {
+ messageEl.textContent = updates.message;
+ }
+ }
+
+ // Update timeout
+ if (updates.timeout !== undefined) {
+ if (notification.timer) {
+ clearTimeout(notification.timer);
+ }
+
+ if (updates.timeout > 0) {
+ notification.timer = setTimeout(() => {
+ this.dismiss(id);
+ }, updates.timeout);
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Enforce maximum number of notifications
+ */
+ enforceMaxNotifications() {
+ const notifications = Array.from(this.notifications.values())
+ .sort((a, b) => a.createdAt - b.createdAt);
+
+ while (notifications.length >= this.config.maxNotifications) {
+ const oldest = notifications.shift();
+ if (oldest && !oldest.persistent) {
+ this.dismiss(oldest.id);
+ }
+ }
+ }
+
+ /**
+ * Convenience methods for different notification types
+ */
+ success(title, message, options = {}) {
+ return this.show({
+ type: 'success',
+ title,
+ message,
+ ...options
+ });
+ }
+
+ error(title, message, options = {}) {
+ return this.show({
+ type: 'error',
+ title,
+ message,
+ ...options
+ });
+ }
+
+ warning(title, message, options = {}) {
+ return this.show({
+ type: 'warning',
+ title,
+ message,
+ ...options
+ });
+ }
+
+ info(title, message, options = {}) {
+ return this.show({
+ type: 'info',
+ title,
+ message,
+ ...options
+ });
+ }
+
+ /**
+ * Get notification statistics
+ */
+ getStats() {
+ return {
+ total: this.notifications.size,
+ byType: this.getNotificationsByType(),
+ oldestTimestamp: this.getOldestNotificationTime(),
+ newestTimestamp: this.getNewestNotificationTime()
+ };
+ }
+
+ /**
+ * Get notifications grouped by type
+ */
+ getNotificationsByType() {
+ const byType = {};
+
+ for (const notification of this.notifications.values()) {
+ byType[notification.type] = (byType[notification.type] || 0) + 1;
+ }
+
+ return byType;
+ }
+
+ /**
+ * Get oldest notification timestamp
+ */
+ getOldestNotificationTime() {
+ const notifications = Array.from(this.notifications.values());
+ if (notifications.length === 0) return null;
+
+ return Math.min(...notifications.map(n => n.createdAt.getTime()));
+ }
+
+ /**
+ * Get newest notification timestamp
+ */
+ getNewestNotificationTime() {
+ const notifications = Array.from(this.notifications.values());
+ if (notifications.length === 0) return null;
+
+ return Math.max(...notifications.map(n => n.createdAt.getTime()));
+ }
+
+ /**
+ * Escape HTML to prevent XSS
+ */
+ escapeHtml(unsafe) {
+ return unsafe
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+
+ /**
+ * Cleanup
+ */
+ destroy() {
+ console.log('🧹 Cleaning up Notification Manager...');
+
+ this.dismissAll();
+
+ if (this.container && this.container.parentNode) {
+ this.container.parentNode.removeChild(this.container);
+ }
+
+ console.log('✅ Notification Manager cleanup complete');
+ }
+}
\ No newline at end of file
diff --git a/vna_system/web_ui/static/js/modules/storage.js b/vna_system/web_ui/static/js/modules/storage.js
new file mode 100644
index 0000000..bf6f265
--- /dev/null
+++ b/vna_system/web_ui/static/js/modules/storage.js
@@ -0,0 +1,453 @@
+/**
+ * Storage Manager
+ * Handles localStorage persistence and user preferences
+ */
+
+export class StorageManager {
+ constructor() {
+ this.prefix = 'vna_dashboard_';
+ this.isAvailable = this.checkAvailability();
+
+ // Storage keys
+ this.keys = {
+ preferences: 'preferences',
+ chartData: 'chart_data',
+ session: 'session_data',
+ debug: 'debug_info'
+ };
+
+ console.log(`💾 Storage Manager initialized (${this.isAvailable ? 'available' : 'not available'})`);
+ }
+
+ /**
+ * Check if localStorage is available
+ */
+ checkAvailability() {
+ try {
+ const test = '__storage_test__';
+ localStorage.setItem(test, test);
+ localStorage.removeItem(test);
+ return true;
+ } catch (e) {
+ console.warn('⚠️ localStorage not available:', e.message);
+ return false;
+ }
+ }
+
+ /**
+ * Get full storage key with prefix
+ */
+ getKey(key) {
+ return `${this.prefix}${key}`;
+ }
+
+ /**
+ * Save data to localStorage
+ */
+ async save(key, data) {
+ if (!this.isAvailable) {
+ console.warn('⚠️ Storage not available, cannot save:', key);
+ return false;
+ }
+
+ try {
+ const serialized = JSON.stringify({
+ data,
+ timestamp: Date.now(),
+ version: '1.0'
+ });
+
+ localStorage.setItem(this.getKey(key), serialized);
+ console.log(`💾 Saved to storage: ${key}`);
+ return true;
+
+ } catch (error) {
+ console.error('❌ Failed to save to storage:', error);
+
+ // Handle quota exceeded error
+ if (error.name === 'QuotaExceededError') {
+ console.log('🧹 Storage quota exceeded, cleaning up...');
+ this.cleanup();
+
+ // Try again after cleanup
+ try {
+ localStorage.setItem(this.getKey(key), serialized);
+ console.log(`💾 Saved to storage after cleanup: ${key}`);
+ return true;
+ } catch (retryError) {
+ console.error('❌ Still failed after cleanup:', retryError);
+ }
+ }
+
+ return false;
+ }
+ }
+
+ /**
+ * Load data from localStorage
+ */
+ async load(key) {
+ if (!this.isAvailable) {
+ console.warn('⚠️ Storage not available, cannot load:', key);
+ return null;
+ }
+
+ try {
+ const serialized = localStorage.getItem(this.getKey(key));
+ if (!serialized) {
+ return null;
+ }
+
+ const parsed = JSON.parse(serialized);
+
+ // Validate structure
+ if (!parsed.data || !parsed.timestamp) {
+ console.warn('⚠️ Invalid storage data format:', key);
+ this.remove(key); // Clean up invalid data
+ return null;
+ }
+
+ console.log(`💾 Loaded from storage: ${key} (${new Date(parsed.timestamp).toLocaleString()})`);
+ return parsed.data;
+
+ } catch (error) {
+ console.error('❌ Failed to load from storage:', error);
+ this.remove(key); // Clean up corrupted data
+ return null;
+ }
+ }
+
+ /**
+ * Remove data from localStorage
+ */
+ async remove(key) {
+ if (!this.isAvailable) return false;
+
+ try {
+ localStorage.removeItem(this.getKey(key));
+ console.log(`🗑️ Removed from storage: ${key}`);
+ return true;
+ } catch (error) {
+ console.error('❌ Failed to remove from storage:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Check if key exists in storage
+ */
+ async exists(key) {
+ if (!this.isAvailable) return false;
+
+ try {
+ return localStorage.getItem(this.getKey(key)) !== null;
+ } catch (error) {
+ console.error('❌ Failed to check storage existence:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Save user preferences
+ */
+ async savePreferences(preferences) {
+ const data = {
+ ...preferences,
+ lastUpdated: Date.now()
+ };
+
+ return await this.save(this.keys.preferences, data);
+ }
+
+ /**
+ * Load user preferences
+ */
+ async getPreferences() {
+ return await this.load(this.keys.preferences);
+ }
+
+ /**
+ * Save chart data for offline viewing
+ */
+ async saveChartData(chartData) {
+ // Limit size to prevent storage overflow
+ const maxCharts = 10;
+ const limitedData = {};
+
+ const chartNames = Object.keys(chartData).slice(-maxCharts);
+ chartNames.forEach(name => {
+ // Keep only last 100 data points per chart
+ const data = chartData[name];
+ if (Array.isArray(data)) {
+ limitedData[name] = data.slice(-100);
+ } else {
+ limitedData[name] = data;
+ }
+ });
+
+ return await this.save(this.keys.chartData, limitedData);
+ }
+
+ /**
+ * Load saved chart data
+ */
+ async getChartData() {
+ return await this.load(this.keys.chartData);
+ }
+
+ /**
+ * Save session data (temporary data that expires)
+ */
+ async saveSession(sessionData) {
+ const data = {
+ ...sessionData,
+ expiresAt: Date.now() + (24 * 60 * 60 * 1000) // 24 hours
+ };
+
+ return await this.save(this.keys.session, data);
+ }
+
+ /**
+ * Load session data (with expiration check)
+ */
+ async getSession() {
+ const data = await this.load(this.keys.session);
+
+ if (data && data.expiresAt) {
+ if (Date.now() > data.expiresAt) {
+ console.log('🕒 Session data expired, removing...');
+ await this.remove(this.keys.session);
+ return null;
+ }
+ }
+
+ return data;
+ }
+
+ /**
+ * Save debug information
+ */
+ async saveDebugInfo(debugInfo) {
+ const data = {
+ userAgent: navigator.userAgent,
+ timestamp: Date.now(),
+ url: window.location.href,
+ ...debugInfo
+ };
+
+ return await this.save(this.keys.debug, data);
+ }
+
+ /**
+ * Get debug information
+ */
+ async getDebugInfo() {
+ return await this.load(this.keys.debug);
+ }
+
+ /**
+ * Get storage usage statistics
+ */
+ getStorageStats() {
+ if (!this.isAvailable) {
+ return { available: false };
+ }
+
+ try {
+ // Calculate used storage
+ let totalSize = 0;
+ const itemSizes = {};
+
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && key.startsWith(this.prefix)) {
+ const value = localStorage.getItem(key);
+ const size = value ? value.length : 0;
+ totalSize += size;
+ itemSizes[key.replace(this.prefix, '')] = size;
+ }
+ }
+
+ return {
+ available: true,
+ totalSize,
+ totalSizeFormatted: this.formatBytes(totalSize),
+ itemSizes,
+ itemCount: Object.keys(itemSizes).length,
+ storageQuotaMB: this.estimateStorageQuota()
+ };
+
+ } catch (error) {
+ console.error('❌ Failed to calculate storage stats:', error);
+ return { available: false, error: error.message };
+ }
+ }
+
+ /**
+ * Estimate storage quota (very rough estimate)
+ */
+ estimateStorageQuota() {
+ // Most browsers have 5-10MB localStorage quota
+ return 5; // MB
+ }
+
+ /**
+ * Format bytes to human readable format
+ */
+ formatBytes(bytes) {
+ if (bytes === 0) return '0 Bytes';
+
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+
+ /**
+ * Cleanup old or unnecessary data
+ */
+ async cleanup() {
+ if (!this.isAvailable) return;
+
+ console.log('🧹 Cleaning up storage...');
+
+ try {
+ const keysToRemove = [];
+ const cutoffTime = Date.now() - (7 * 24 * 60 * 60 * 1000); // 7 days ago
+
+ // Find keys to remove
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && key.startsWith(this.prefix)) {
+ try {
+ const value = localStorage.getItem(key);
+ const parsed = JSON.parse(value);
+
+ // Remove old data
+ if (parsed.timestamp && parsed.timestamp < cutoffTime) {
+ keysToRemove.push(key);
+ }
+
+ } catch (parseError) {
+ // Remove corrupted data
+ keysToRemove.push(key);
+ }
+ }
+ }
+
+ // Remove identified keys
+ keysToRemove.forEach(key => {
+ localStorage.removeItem(key);
+ console.log(`🗑️ Cleaned up: ${key}`);
+ });
+
+ console.log(`🧹 Cleanup complete. Removed ${keysToRemove.length} items.`);
+
+ } catch (error) {
+ console.error('❌ Cleanup failed:', error);
+ }
+ }
+
+ /**
+ * Clear all application data
+ */
+ async clearAll() {
+ if (!this.isAvailable) return false;
+
+ console.log('🗑️ Clearing all storage data...');
+
+ try {
+ const keysToRemove = [];
+
+ // Find all our keys
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && key.startsWith(this.prefix)) {
+ keysToRemove.push(key);
+ }
+ }
+
+ // Remove all keys
+ keysToRemove.forEach(key => {
+ localStorage.removeItem(key);
+ });
+
+ console.log(`🗑️ Cleared ${keysToRemove.length} items from storage`);
+ return true;
+
+ } catch (error) {
+ console.error('❌ Failed to clear storage:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Export all data for backup
+ */
+ async exportData() {
+ if (!this.isAvailable) return null;
+
+ try {
+ const exportData = {
+ timestamp: Date.now(),
+ version: '1.0',
+ data: {}
+ };
+
+ // Export all our data
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && key.startsWith(this.prefix)) {
+ const shortKey = key.replace(this.prefix, '');
+ const value = localStorage.getItem(key);
+ exportData.data[shortKey] = JSON.parse(value);
+ }
+ }
+
+ console.log('📤 Exported storage data');
+ return exportData;
+
+ } catch (error) {
+ console.error('❌ Failed to export data:', error);
+ return null;
+ }
+ }
+
+ /**
+ * Import data from backup
+ */
+ async importData(importData) {
+ if (!this.isAvailable) return false;
+
+ try {
+ if (!importData.data || typeof importData.data !== 'object') {
+ throw new Error('Invalid import data format');
+ }
+
+ // Import each item
+ for (const [key, value] of Object.entries(importData.data)) {
+ const fullKey = this.getKey(key);
+ localStorage.setItem(fullKey, JSON.stringify(value));
+ }
+
+ console.log(`📥 Imported ${Object.keys(importData.data).length} items`);
+ return true;
+
+ } catch (error) {
+ console.error('❌ Failed to import data:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Cleanup on destroy
+ */
+ destroy() {
+ console.log('🧹 Storage Manager cleanup...');
+ // Perform any necessary cleanup
+ // For now, just run a cleanup to free up space
+ this.cleanup();
+ console.log('✅ Storage Manager cleanup complete');
+ }
+}
\ No newline at end of file
diff --git a/vna_system/web_ui/static/js/modules/ui.js b/vna_system/web_ui/static/js/modules/ui.js
new file mode 100644
index 0000000..212ea33
--- /dev/null
+++ b/vna_system/web_ui/static/js/modules/ui.js
@@ -0,0 +1,529 @@
+/**
+ * UI Manager
+ * Handles user interface interactions and state management
+ */
+
+export class UIManager {
+ constructor(notifications) {
+ this.notifications = notifications;
+
+ // UI Elements
+ this.elements = {
+ connectionStatus: null,
+ processorToggles: null,
+ sweepCount: null,
+ dataRate: null,
+ clearChartsBtn: null,
+ exportBtn: null,
+ navButtons: null,
+ views: null
+ };
+
+ // State
+ this.currentView = 'dashboard';
+ this.connectionStatus = 'disconnected';
+ this.processors = new Map(); // processor_name -> { enabled, count }
+
+ // 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
+ }
+ };
+ }
+
+ /**
+ * Initialize UI Manager
+ */
+ async init() {
+ console.log('🎨 Initializing UI Manager...');
+
+ // Get DOM elements
+ this.findElements();
+
+ // Set up event listeners
+ this.setupEventListeners();
+
+ // Initialize UI state
+ this.initializeUIState();
+
+ // Start update loop
+ this.startUpdateLoop();
+
+ console.log('✅ UI Manager initialized');
+ }
+
+ /**
+ * Find and cache DOM elements
+ */
+ 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}`);
+ }
+ }
+ }
+
+ /**
+ * Set up event listeners
+ */
+ setupEventListeners() {
+ // Navigation
+ this.elements.navButtons.forEach(button => {
+ button.addEventListener('click', (e) => {
+ const view = e.currentTarget.dataset.view;
+ this.switchView(view);
+ });
+ });
+
+ // Note: Clear charts functionality removed - use processor toggles instead
+
+ // Export button
+ this.elements.exportBtn.addEventListener('click', () => {
+ this.triggerExportData();
+ });
+
+ // 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);
+ }
+ });
+
+ // Window resize
+ 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)
+ */
+ 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);
+ });
+
+ this.currentView = viewName;
+ this.emitEvent('viewChange', viewName);
+ }
+
+ /**
+ * Update connection status
+ */
+ setConnectionStatus(status) {
+ if (this.connectionStatus === status) return;
+
+ console.log(`🔌 Connection status: ${status}`);
+ this.connectionStatus = status;
+
+ const statusElement = this.elements.connectionStatus;
+ const textElement = statusElement.querySelector('.status-indicator__text');
+
+ // Remove existing status classes
+ statusElement.classList.remove(
+ 'status-indicator--connected',
+ 'status-indicator--connecting',
+ 'status-indicator--disconnected'
+ );
+
+ // Add new status class and update text
+ switch (status) {
+ case 'connected':
+ statusElement.classList.add('status-indicator--connected');
+ textElement.textContent = 'Connected';
+ break;
+ case 'connecting':
+ statusElement.classList.add('status-indicator--connecting');
+ textElement.textContent = 'Connecting...';
+ break;
+ case 'disconnected':
+ default:
+ statusElement.classList.add('status-indicator--disconnected');
+ textElement.textContent = 'Disconnected';
+ break;
+ }
+ }
+
+ /**
+ * Update processor toggles
+ */
+ updateProcessorToggles(processors) {
+ if (!this.elements.processorToggles) return;
+
+ // Clear existing toggles
+ this.elements.processorToggles.innerHTML = '';
+
+ // Create toggles for each processor
+ processors.forEach(processor => {
+ const toggle = this.createProcessorToggle(processor);
+ this.elements.processorToggles.appendChild(toggle);
+ });
+
+ // Update Lucide icons
+ if (typeof lucide !== 'undefined') {
+ lucide.createIcons({ attrs: { 'stroke-width': 1.5 } });
+ }
+ }
+
+ /**
+ * Create processor toggle element
+ */
+ createProcessorToggle(processor) {
+ const toggle = document.createElement('div');
+ toggle.className = `processor-toggle ${processor.enabled ? 'processor-toggle--active' : ''}`;
+ toggle.dataset.processor = processor.name;
+
+ toggle.innerHTML = `
+
+ ${this.formatProcessorName(processor.name)}
+ ${processor.count || 0}
+ `;
+
+ 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);
+ this.refreshProcessorToggles();
+ }
+
+ // Increment count
+ processor.count++;
+
+ // 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;
+ }
+ }
+ }
+
+ /**
+ * Refresh processor toggles display
+ */
+ refreshProcessorToggles() {
+ const processors = Array.from(this.processors.entries()).map(([name, data]) => ({
+ name,
+ enabled: data.enabled,
+ count: data.count
+ }));
+
+ this.updateProcessorToggles(processors);
+ }
+
+ /**
+ * Update statistics display
+ */
+ updateStats(newStats) {
+ if (newStats.sweepCount !== undefined) {
+ this.stats.sweepCount = newStats.sweepCount;
+ }
+
+ if (newStats.lastUpdate) {
+ this.updateDataRate(newStats.lastUpdate);
+ }
+
+ 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`;
+ }
+ }
+
+ /**
+ * 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);
+ return;
+ }
+
+ this.toggleProcessor(processorName, enabled);
+ }
+
+ /**
+ * Format processor name for display
+ */
+ formatProcessorName(processorName) {
+ return processorName
+ .split('_')
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
+ .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
+ }
+
+ /**
+ * Update system status (for future use)
+ */
+ updateSystemStatus(statusData) {
+ console.log('📊 System status update:', statusData);
+ // Future implementation for system health monitoring
+ }
+
+ /**
+ * Set theme (for future use)
+ */
+ setTheme(theme) {
+ console.log(`🎨 Setting theme: ${theme}`);
+ document.documentElement.setAttribute('data-theme', theme);
+ }
+
+ /**
+ * 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);
+ }
+ });
+ }
+ }
+
+ /**
+ * Utility: Debounce function
+ */
+ debounce(func, wait) {
+ let timeout;
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout);
+ func(...args);
+ };
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ };
+ }
+
+ /**
+ * Get UI statistics
+ */
+ getStats() {
+ return {
+ currentView: this.currentView,
+ connectionStatus: this.connectionStatus,
+ processorsCount: this.processors.size,
+ ...this.stats
+ };
+ }
+
+ /**
+ * Cleanup
+ */
+ destroy() {
+ console.log('🧹 Cleaning up UI Manager...');
+
+ // Clear event handlers
+ Object.keys(this.eventHandlers).forEach(key => {
+ this.eventHandlers[key] = [];
+ });
+
+ // Clear processors
+ this.processors.clear();
+
+ console.log('✅ UI Manager cleanup complete');
+ }
+}
\ No newline at end of file
diff --git a/vna_system/web_ui/static/js/modules/websocket.js b/vna_system/web_ui/static/js/modules/websocket.js
new file mode 100644
index 0000000..4920341
--- /dev/null
+++ b/vna_system/web_ui/static/js/modules/websocket.js
@@ -0,0 +1,460 @@
+/**
+ * WebSocket Manager
+ * Handles real-time communication with the VNA backend
+ */
+
+export class WebSocketManager {
+ constructor(config, notifications) {
+ this.config = config;
+ this.notifications = notifications;
+ this.ws = null;
+ this.isConnected = false;
+ this.isConnecting = false;
+ this.reconnectAttempts = 0;
+ this.reconnectTimer = null;
+ this.pingTimer = null;
+ this.lastPing = null;
+ this.eventListeners = new Map();
+
+ // Message queue for when disconnected
+ this.messageQueue = [];
+ this.maxQueueSize = 100;
+
+ // Statistics
+ this.stats = {
+ messagesReceived: 0,
+ messagesPerSecond: 0,
+ lastMessageTime: null,
+ connectionTime: null,
+ 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');
+ return;
+ }
+
+ try {
+ this.isConnecting = true;
+ this.emit('connecting');
+
+ console.log(`🔌 Connecting to WebSocket: ${this.config.url}`);
+
+ 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);
+ this.handleConnectionError(error);
+ throw error;
+ }
+ }
+
+ /**
+ * Wait for WebSocket connection with timeout
+ */
+ waitForConnection(timeoutMs) {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error('WebSocket connection timeout'));
+ }, timeoutMs);
+
+ const checkConnection = () => {
+ if (this.isConnected) {
+ clearTimeout(timeout);
+ resolve();
+ } 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();
+ const interval = setInterval(() => {
+ checkConnection();
+ 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);
+ };
+ });
+ }
+
+ /**
+ * Set up WebSocket event handlers
+ */
+ setupWebSocketEvents() {
+ if (!this.ws) return;
+
+ this.ws.onopen = (event) => {
+ console.log('✅ WebSocket connected');
+ this.isConnected = true;
+ this.isConnecting = false;
+ this.reconnectAttempts = 0;
+ this.stats.connectionTime = new Date();
+
+ this.startPingPong();
+ this.processPendingMessages();
+ this.emit('connected', event);
+
+ this.notifications.show({
+ type: 'success',
+ title: 'Connected',
+ message: 'Real-time connection established'
+ });
+ };
+
+ this.ws.onmessage = (event) => {
+ try {
+ this.handleMessage(event.data);
+ } catch (error) {
+ console.error('❌ Error processing WebSocket message:', error);
+ this.notifications.show({
+ type: 'error',
+ title: 'Message Error',
+ message: 'Failed to process received data'
+ });
+ }
+ };
+
+ this.ws.onerror = (error) => {
+ console.error('❌ WebSocket error:', error);
+ this.handleConnectionError(error);
+ };
+
+ this.ws.onclose = (event) => {
+ console.log('🔌 WebSocket closed:', event.code, event.reason);
+ this.handleDisconnection(event);
+ };
+ }
+
+ /**
+ * Handle incoming messages
+ */
+ handleMessage(data) {
+ // Update statistics
+ this.stats.messagesReceived++;
+ this.stats.lastMessageTime = new Date();
+ this.stats.bytesReceived += data.length;
+
+ // Rate limiting check
+ this.updateMessageRate();
+
+ try {
+ let parsedData;
+
+ // 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');
+ return;
+ }
+
+ // Emit data event
+ this.emit('data', parsedData);
+
+ } catch (error) {
+ console.error('❌ Failed to parse WebSocket message:', error);
+ 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.stats.messagesPerSecond = this.messageRateCounter.length;
+ }
+
+ /**
+ * Handle ping message
+ */
+ handlePing() {
+ if (this.ws && 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`);
+ this.lastPing = null;
+ }
+ }
+
+ /**
+ * Start ping-pong mechanism
+ */
+ startPingPong() {
+ this.pingTimer = setInterval(() => {
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ this.lastPing = Date.now();
+ this.ws.send('ping');
+ }
+ }, 30000); // Ping every 30 seconds
+ }
+
+ /**
+ * Handle connection errors
+ */
+ handleConnectionError(error) {
+ console.error('❌ WebSocket connection error:', error);
+
+ if (!this.isConnected) {
+ this.notifications.show({
+ type: 'error',
+ title: 'Connection Failed',
+ message: 'Unable to establish real-time connection'
+ });
+ }
+ }
+
+ /**
+ * 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;
+ }
+
+ this.emit('disconnected', event);
+
+ if (wasConnected) {
+ 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
+ );
+
+ 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({
+ 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();
+ }
+ }
+
+ /**
+ * 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)');
+ } else {
+ console.warn('⚠️ Message queue full, dropping message');
+ }
+ return false;
+ }
+
+ try {
+ const message = typeof data === 'string' ? data : JSON.stringify(data);
+ this.ws.send(message);
+ return true;
+ } catch (error) {
+ console.error('❌ Failed to send message:', error);
+ 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);
+ });
+ }
+ }
+
+ /**
+ * 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;
+ }
+
+ this.isConnected = false;
+ this.isConnecting = false;
+ this.reconnectAttempts = 0;
+ }
+
+ /**
+ * Get connection statistics
+ */
+ getStats() {
+ return {
+ ...this.stats,
+ isConnected: this.isConnected,
+ isConnecting: this.isConnecting,
+ reconnectAttempts: this.reconnectAttempts,
+ queuedMessages: this.messageQueue.length
+ };
+ }
+
+ /**
+ * 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 = [];
+ }
+}
\ No newline at end of file
diff --git a/vna_system/web_ui/templates/index.html b/vna_system/web_ui/templates/index.html
new file mode 100644
index 0000000..85f8feb
--- /dev/null
+++ b/vna_system/web_ui/templates/index.html
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+ VNA System Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No Data Yet
+
+ Waiting for sweep data from the VNA system...
+
+
+
+
+
+
+
+
+
+
+
Settings
+
Settings panel will be implemented in future versions.
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file