added calibration

This commit is contained in:
Ayzen
2025-09-24 18:52:34 +03:00
parent 664314097f
commit 8f460471be
35 changed files with 30560 additions and 325 deletions

View File

@ -0,0 +1,301 @@
from fastapi import APIRouter, HTTPException
from typing import List
import vna_system.core.singletons as singletons
from vna_system.core.settings.calibration_manager import CalibrationStandard
from vna_system.api.models.settings import (
PresetModel,
CalibrationModel,
SettingsStatusModel,
SetPresetRequest,
StartCalibrationRequest,
CalibrateStandardRequest,
SaveCalibrationRequest,
SetCalibrationRequest,
RemoveStandardRequest,
WorkingCalibrationModel
)
router = APIRouter(prefix="/api/v1/settings", tags=["settings"])
@router.get("/status", response_model=SettingsStatusModel)
async def get_status():
"""Get current settings status"""
try:
status = singletons.settings_manager.get_status_summary()
return status
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/presets", response_model=List[PresetModel])
async def get_presets(mode: str | None = None):
"""Get all available configuration presets, optionally filtered by mode"""
try:
if mode:
from vna_system.core.settings.preset_manager import VNAMode
try:
vna_mode = VNAMode(mode.lower())
presets = singletons.settings_manager.get_presets_by_mode(vna_mode)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid mode: {mode}")
else:
presets = singletons.settings_manager.get_available_presets()
return [
PresetModel(
filename=preset.filename,
mode=preset.mode.value,
start_freq=preset.start_freq,
stop_freq=preset.stop_freq,
points=preset.points,
bandwidth=preset.bandwidth
)
for preset in presets
]
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/preset/set")
async def set_preset(request: SetPresetRequest):
"""Set current configuration preset"""
try:
# Find preset by filename
presets = singletons.settings_manager.get_available_presets()
preset = next((p for p in presets if p.filename == request.filename), None)
if not preset:
raise HTTPException(status_code=404, detail=f"Preset not found: {request.filename}")
# Clear current calibration when changing preset
singletons.settings_manager.calibration_manager.clear_current_calibration()
singletons.settings_manager.set_current_preset(preset)
return {"success": True, "message": f"Preset set to {request.filename}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/preset/current", response_model=PresetModel | None)
async def get_current_preset():
"""Get currently selected configuration preset"""
try:
preset = singletons.settings_manager.get_current_preset()
if not preset:
return None
return PresetModel(
filename=preset.filename,
mode=preset.mode.value,
start_freq=preset.start_freq,
stop_freq=preset.stop_freq,
points=preset.points,
bandwidth=preset.bandwidth
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/calibrations", response_model=List[CalibrationModel])
async def get_calibrations(preset_filename: str | None = None):
"""Get available calibrations for current or specified preset"""
try:
preset = None
if preset_filename:
presets = singletons.settings_manager.get_available_presets()
preset = next((p for p in presets if p.filename == preset_filename), None)
if not preset:
raise HTTPException(status_code=404, detail=f"Preset not found: {preset_filename}")
calibrations = singletons.settings_manager.get_available_calibrations(preset)
# Get detailed info for each calibration
calibration_details = []
current_preset = preset or singletons.settings_manager.get_current_preset()
if current_preset:
for calib_name in calibrations:
info = singletons.settings_manager.get_calibration_info(calib_name, current_preset)
# Convert standards format if needed
standards = info.get('standards', {})
if isinstance(standards, list):
# If standards is a list (from complete calibration), convert to dict
required_standards = singletons.settings_manager.get_required_standards(current_preset.mode)
standards = {std.value: std.value in standards for std in required_standards}
calibration_details.append(CalibrationModel(
name=calib_name,
is_complete=info.get('is_complete', False),
standards=standards
))
return calibration_details
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/calibration/start")
async def start_calibration(request: StartCalibrationRequest):
"""Start new calibration for current or specified preset"""
try:
preset = None
if request.preset_filename:
presets = singletons.settings_manager.get_available_presets()
preset = next((p for p in presets if p.filename == request.preset_filename), None)
if not preset:
raise HTTPException(status_code=404, detail=f"Preset not found: {request.preset_filename}")
calibration_set = singletons.settings_manager.start_new_calibration(preset)
required_standards = singletons.settings_manager.get_required_standards(calibration_set.preset.mode)
return {
"success": True,
"message": "Calibration started",
"preset": calibration_set.preset.filename,
"required_standards": [s.value for s in required_standards]
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/calibration/add-standard")
async def add_calibration_standard(request: CalibrateStandardRequest):
"""Add calibration standard from latest sweep"""
try:
# Validate standard
try:
standard = CalibrationStandard(request.standard)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid calibration standard: {request.standard}")
# Capture from data acquisition
sweep_number = singletons.settings_manager.capture_calibration_standard_from_acquisition(
standard, singletons.vna_data_acquisition_instance
)
# Get current working calibration status
working_calib = singletons.settings_manager.get_current_working_calibration()
progress = working_calib.get_progress() if working_calib else (0, 0)
return {
"success": True,
"message": f"Added {standard.value} standard from sweep {sweep_number}",
"sweep_number": sweep_number,
"progress": f"{progress[0]}/{progress[1]}",
"is_complete": working_calib.is_complete() if working_calib else False
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/calibration/save")
async def save_calibration(request: SaveCalibrationRequest):
"""Save current working calibration set"""
try:
calibration_set = singletons.settings_manager.save_calibration_set(request.name)
return {
"success": True,
"message": f"Calibration '{request.name}' saved successfully",
"preset": calibration_set.preset.filename,
"standards": list(calibration_set.standards.keys())
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/calibration/set")
async def set_calibration(request: SetCalibrationRequest):
"""Set current active calibration"""
try:
preset = None
if request.preset_filename:
presets = singletons.settings_manager.get_available_presets()
preset = next((p for p in presets if p.filename == request.preset_filename), None)
if not preset:
raise HTTPException(status_code=404, detail=f"Preset not found: {request.preset_filename}")
singletons.settings_manager.set_current_calibration(request.name, preset)
return {
"success": True,
"message": f"Calibration set to '{request.name}'"
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/working-calibration", response_model=WorkingCalibrationModel)
async def get_working_calibration():
"""Get current working calibration status"""
try:
working_calib = singletons.settings_manager.get_current_working_calibration()
if not working_calib:
return WorkingCalibrationModel(active=False)
completed, total = working_calib.get_progress()
missing_standards = working_calib.get_missing_standards()
return WorkingCalibrationModel(
active=True,
preset=working_calib.preset.filename,
progress=f"{completed}/{total}",
is_complete=working_calib.is_complete(),
completed_standards=[s.value for s in working_calib.standards.keys()],
missing_standards=[s.value for s in missing_standards]
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/calibration/remove-standard")
async def remove_calibration_standard(request: RemoveStandardRequest):
"""Remove calibration standard from current working set"""
try:
# Validate standard
try:
standard = CalibrationStandard(request.standard)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid calibration standard: {request.standard}")
singletons.settings_manager.remove_calibration_standard(standard)
# Get current working calibration status
working_calib = singletons.settings_manager.get_current_working_calibration()
progress = working_calib.get_progress() if working_calib else (0, 0)
return {
"success": True,
"message": f"Removed {standard.value} standard",
"progress": f"{progress[0]}/{progress[1]}",
"is_complete": working_calib.is_complete() if working_calib else False
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/calibration/current")
async def get_current_calibration():
"""Get currently selected calibration details"""
try:
current_calib = singletons.settings_manager.get_current_calibration()
if not current_calib:
return {"active": False}
return {
"active": True,
"preset": {
"filename": current_calib.preset.filename,
"mode": current_calib.preset.mode.value
},
"calibration_name": current_calib.name,
"standards": [s.value for s in current_calib.standards.keys()],
"is_complete": current_calib.is_complete()
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -14,7 +14,7 @@ from pathlib import Path
import vna_system.core.singletons as singletons
from vna_system.core.processing.sweep_processor import SweepProcessingManager
from vna_system.core.processing.websocket_handler import WebSocketManager
from vna_system.api.endpoints import health, processing, web_ui
from vna_system.api.endpoints import health, processing, settings, web_ui
from vna_system.api.websockets import processing as ws_processing
@ -117,6 +117,7 @@ else:
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(settings.router)
app.include_router(ws_processing.router)

View File

@ -0,0 +1,59 @@
from pydantic import BaseModel
from typing import List, Dict, Any
class PresetModel(BaseModel):
filename: str
mode: str
start_freq: float | None
stop_freq: float | None
points: int | None
bandwidth: float | None
class CalibrationModel(BaseModel):
name: str
is_complete: bool
standards: Dict[str, bool]
class SettingsStatusModel(BaseModel):
current_preset: PresetModel | None
current_calibration: Dict[str, Any] | None
working_calibration: Dict[str, Any] | None
available_presets: int
available_calibrations: int
class SetPresetRequest(BaseModel):
filename: str
class StartCalibrationRequest(BaseModel):
preset_filename: str | None = None
class CalibrateStandardRequest(BaseModel):
standard: str
class SaveCalibrationRequest(BaseModel):
name: str
class SetCalibrationRequest(BaseModel):
name: str
preset_filename: str | None = None
class RemoveStandardRequest(BaseModel):
standard: str
class WorkingCalibrationModel(BaseModel):
active: bool
preset: str | None = None
progress: str | None = None
is_complete: bool | None = None
completed_standards: List[str] | None = None
missing_standards: List[str] | None = None

View File

@ -0,0 +1 @@
config_inputs/s11_start100_stop8800_points1000_bw1khz.bin

View File

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

View File

@ -0,0 +1,18 @@
{
"preset": {
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
"mode": "s11",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "lol",
"standards": [
"open",
"short",
"load"
],
"created_timestamp": "2025-09-24T17:28:43.780211",
"is_complete": true
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,16 @@
{
"preset": {
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
"mode": "s11",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "lol",
"standard": "load",
"sweep_number": 736,
"sweep_timestamp": 1758724118.5653427,
"created_timestamp": "2025-09-24T17:28:43.780150",
"total_points": 1000
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,16 @@
{
"preset": {
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
"mode": "s11",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "lol",
"standard": "open",
"sweep_number": 731,
"sweep_timestamp": 1758724108.0550592,
"created_timestamp": "2025-09-24T17:28:43.775117",
"total_points": 1000
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,16 @@
{
"preset": {
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
"mode": "s11",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "lol",
"standard": "short",
"sweep_number": 733,
"sweep_timestamp": 1758724112.282575,
"created_timestamp": "2025-09-24T17:28:43.777473",
"total_points": 1000
}

View File

@ -0,0 +1,18 @@
{
"preset": {
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
"mode": "s11",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "tuncTuncTuncSahur",
"standards": [
"open",
"short",
"load"
],
"created_timestamp": "2025-09-24T17:55:12.053850",
"is_complete": true
}

View File

@ -0,0 +1,16 @@
{
"preset": {
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
"mode": "s11",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "tuncTuncTuncSahur",
"standard": "load",
"sweep_number": 1221,
"sweep_timestamp": 1758725709.366605,
"created_timestamp": "2025-09-24T17:55:12.053789",
"total_points": 1000
}

View File

@ -0,0 +1,16 @@
{
"preset": {
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
"mode": "s11",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "tuncTuncTuncSahur",
"standard": "open",
"sweep_number": 1217,
"sweep_timestamp": 1758725700.971026,
"created_timestamp": "2025-09-24T17:55:12.049478",
"total_points": 1000
}

View File

@ -0,0 +1,16 @@
{
"preset": {
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
"mode": "s11",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "tuncTuncTuncSahur",
"standard": "short",
"sweep_number": 1219,
"sweep_timestamp": 1758725705.1671622,
"created_timestamp": "2025-09-24T17:55:12.051603",
"total_points": 1000
}

View File

@ -0,0 +1,16 @@
{
"preset": {
"filename": "s21_start100_stop8800_points1000_bw1khz.bin",
"mode": "s21",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "bimBimPatapim",
"standards": [
"through"
],
"created_timestamp": "2025-09-24T17:50:35.610853",
"is_complete": true
}

View File

@ -0,0 +1,16 @@
{
"preset": {
"filename": "s21_start100_stop8800_points1000_bw1khz.bin",
"mode": "s21",
"start_freq": 100000000.0,
"stop_freq": 8800000000.0,
"points": 1000,
"bandwidth": 1000.0
},
"calibration_name": "bimBimPatapim",
"standard": "through",
"sweep_number": 1186,
"sweep_timestamp": 1758725431.370608,
"created_timestamp": "2025-09-24T17:50:35.610460",
"total_points": 1000
}

View File

@ -35,7 +35,7 @@ SWEEP_BUFFER_MAX_SIZE = 100 # Maximum number of sweeps to store in circular buf
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
BIN_INPUT_FILE_PATH = "./vna_system/binary_input/current_input.bin" # Symbolic link to the current log file
# Binary log format constants
MAGIC = b"VNALOG1\n"

View File

@ -21,7 +21,7 @@ class VNADataAcquisition:
"""Main data acquisition class with asynchronous sweep collection."""
def __init__(self) -> None:
self.bin_log_path: str = cfg.BIN_LOG_FILE_PATH
self.bin_log_path: str = cfg.BIN_INPUT_FILE_PATH
self.baud: int = cfg.DEFAULT_BAUD_RATE
@ -81,7 +81,6 @@ class VNADataAcquisition:
# Serial management
# --------------------------------------------------------------------- #
def _drain_serial_input(self, ser: serial.Serial) -> None:
"""Drain any pending bytes from the serial input buffer."""
if not ser:

View File

@ -0,0 +1,134 @@
"""
Calibration Processor Module
Applies VNA calibrations to sweep data using stored calibration standards.
Supports both S11 and S21 measurement modes with appropriate correction algorithms.
"""
import numpy as np
from pathlib import Path
from typing import Optional
from vna_system.core.acquisition.sweep_buffer import SweepData
from vna_system.core.settings.preset_manager import VNAMode
from vna_system.core.settings.calibration_manager import CalibrationSet
from vna_system.core.settings.calibration_manager import CalibrationStandard
class CalibrationProcessor:
"""
Processes sweep data by applying VNA calibrations.
For S11 mode: Uses OSL (Open-Short-Load) calibration
For S21 mode: Uses Through calibration
"""
def __init__(self):
pass
def apply_calibration(self, sweep_data: SweepData, calibration_set: CalibrationSet) -> np.ndarray:
"""
Apply calibration to sweep data and return corrected complex array.
Args:
sweep_data: Raw sweep data from VNA
calibration_set: Calibration standards data
Returns:
Complex array with calibration applied
Raises:
ValueError: If calibration is incomplete or mode not supported
"""
if not calibration_set.is_complete():
raise ValueError("Calibration set is incomplete")
# Convert sweep data to complex array
raw_signal = self._sweep_to_complex_array(sweep_data)
# Apply calibration based on measurement mode
if calibration_set.preset.mode == VNAMode.S21:
return self._apply_s21_calibration(raw_signal, calibration_set)
elif calibration_set.preset.mode == VNAMode.S11:
return self._apply_s11_calibration(raw_signal, calibration_set)
else:
raise ValueError(f"Unsupported measurement mode: {calibration_set.preset.mode}")
def _sweep_to_complex_array(self, sweep_data: SweepData) -> np.ndarray:
"""Convert SweepData to complex numpy array."""
complex_data = []
for real, imag in sweep_data.points:
complex_data.append(complex(real, imag))
return np.array(complex_data)
def _apply_s21_calibration(self, raw_signal: np.ndarray, calibration_set: CalibrationSet) -> np.ndarray:
"""
Apply S21 (transmission) calibration using through standard.
Calibrated_S21 = Raw_Signal / Through_Reference
"""
# Get through calibration data
through_sweep = calibration_set.standards[CalibrationStandard.THROUGH]
through_reference = self._sweep_to_complex_array(through_sweep)
# Validate array sizes
if len(raw_signal) != len(through_reference):
raise ValueError("Signal and calibration data have different lengths")
# Avoid division by zero
through_reference = np.where(through_reference == 0, 1e-12, through_reference)
# Apply through calibration
calibrated_signal = raw_signal / through_reference
return calibrated_signal
def _apply_s11_calibration(self, raw_signal: np.ndarray, calibration_set: CalibrationSet) -> np.ndarray:
"""
Apply S11 (reflection) calibration using OSL (Open-Short-Load) method.
This implements the standard OSL error correction:
- Ed (Directivity): Load standard
- Es (Source Match): Calculated from Open, Short, Load
- Er (Reflection Tracking): Calculated from Open, Short, Load
Final correction: S11 = (Raw - Ed) / (Er + Es * (Raw - Ed))
"""
from vna_system.core.settings.calibration_manager import CalibrationStandard
# Get calibration standards
open_sweep = calibration_set.standards[CalibrationStandard.OPEN]
short_sweep = calibration_set.standards[CalibrationStandard.SHORT]
load_sweep = calibration_set.standards[CalibrationStandard.LOAD]
# Convert to complex arrays
open_cal = self._sweep_to_complex_array(open_sweep)
short_cal = self._sweep_to_complex_array(short_sweep)
load_cal = self._sweep_to_complex_array(load_sweep)
# Validate array sizes
if not (len(raw_signal) == len(open_cal) == len(short_cal) == len(load_cal)):
raise ValueError("Signal and calibration data have different lengths")
# Calculate error terms
directivity = load_cal.copy() # Ed = Load
# Source match: Es = (Open + Short - 2*Load) / (Open - Short)
denominator = open_cal - short_cal
denominator = np.where(np.abs(denominator) < 1e-12, 1e-12, denominator)
source_match = (open_cal + short_cal - 2 * load_cal) / denominator
# Reflection tracking: Er = -2 * (Open - Load) * (Short - Load) / (Open - Short)
reflection_tracking = -2 * (open_cal - load_cal) * (short_cal - load_cal) / denominator
# Apply OSL correction
corrected_numerator = raw_signal - directivity
corrected_denominator = reflection_tracking + source_match * corrected_numerator
# Avoid division by zero
corrected_denominator = np.where(np.abs(corrected_denominator) < 1e-12, 1e-12, corrected_denominator)
calibrated_signal = corrected_numerator / corrected_denominator
return calibrated_signal

View File

@ -5,7 +5,6 @@ Magnitude plot processor for sweep data.
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, List, Tuple
@ -17,6 +16,8 @@ import plotly.graph_objects as go
from vna_system.core.acquisition.sweep_buffer import SweepData
from vna_system.core.processing.base_processor import BaseSweepProcessor, ProcessingResult
from vna_system.core.processing.calibration_processor import CalibrationProcessor
import vna_system.core.singletons as singletons
class MagnitudePlotProcessor(BaseSweepProcessor):
@ -30,6 +31,10 @@ class MagnitudePlotProcessor(BaseSweepProcessor):
self.width = config.get("width", 800)
self.height = config.get("height", 600)
# Calibration support
self.apply_calibration = config.get("apply_calibration", True)
self.calibration_processor = CalibrationProcessor()
# Create output directory if it doesn't exist
if self.save_image:
self.output_dir.mkdir(parents=True, exist_ok=True)
@ -43,18 +48,24 @@ class MagnitudePlotProcessor(BaseSweepProcessor):
if not self.should_process(sweep):
return None
# Get current calibration from settings manager
current_calibration = singletons.settings_manager.get_current_calibration()
# Apply calibration if available and enabled
processed_sweep = self._apply_calibration_if_available(sweep, current_calibration)
# Extract magnitude data
magnitude_data = self._extract_magnitude_data(sweep)
magnitude_data = self._extract_magnitude_data(processed_sweep)
if not magnitude_data:
return None
# Create plotly figure for websocket/API consumption
plotly_fig = self._create_plotly_figure(sweep, magnitude_data)
plotly_fig = self._create_plotly_figure(sweep, magnitude_data, current_calibration is not None)
# Save image if requested (using matplotlib)
file_path = None
if self.save_image:
file_path = self._save_matplotlib_image(sweep, magnitude_data)
file_path = self._save_matplotlib_image(sweep, magnitude_data, current_calibration is not None)
# Prepare result data
result_data = {
@ -62,6 +73,8 @@ class MagnitudePlotProcessor(BaseSweepProcessor):
"timestamp": sweep.timestamp,
"total_points": sweep.total_points,
"magnitude_stats": self._calculate_magnitude_stats(magnitude_data),
"calibration_applied": current_calibration is not None and self.apply_calibration,
"calibration_name": current_calibration.name if current_calibration else None,
}
return ProcessingResult(
@ -72,6 +85,29 @@ class MagnitudePlotProcessor(BaseSweepProcessor):
file_path=str(file_path) if file_path else None,
)
def _apply_calibration_if_available(self, sweep: SweepData, calibration_set) -> SweepData:
"""Apply calibration to sweep data if calibration is available and enabled."""
if not self.apply_calibration or not calibration_set:
return sweep
try:
# Apply calibration and get corrected complex array
calibrated_complex = self.calibration_processor.apply_calibration(sweep, calibration_set)
# Convert back to (real, imag) tuples for SweepData
calibrated_points = [(complex_val.real, complex_val.imag) for complex_val in calibrated_complex]
# Create new SweepData with calibrated points
return SweepData(
sweep_number=sweep.sweep_number,
timestamp=sweep.timestamp,
points=calibrated_points,
total_points=len(calibrated_points)
)
except Exception as e:
print(f"Failed to apply calibration: {e}")
return sweep
def _extract_magnitude_data(self, sweep: SweepData) -> List[Tuple[float, float]]:
"""Extract magnitude data from sweep points."""
magnitude_data = []
@ -80,23 +116,29 @@ class MagnitudePlotProcessor(BaseSweepProcessor):
magnitude_data.append((i, magnitude))
return magnitude_data
def _create_plotly_figure(self, sweep: SweepData, magnitude_data: List[Tuple[float, float]]) -> go.Figure:
def _create_plotly_figure(self, sweep: SweepData, magnitude_data: List[Tuple[float, float]], calibrated: bool = False) -> go.Figure:
"""Create plotly figure for magnitude plot."""
indices = [point[0] for point in magnitude_data]
magnitudes = [point[1] for point in magnitude_data]
fig = go.Figure()
# Choose color based on calibration status
line_color = 'green' if calibrated else 'blue'
trace_name = 'Magnitude (Calibrated)' if calibrated else 'Magnitude (Raw)'
fig.add_trace(go.Scatter(
x=indices,
y=magnitudes,
mode='lines',
name='Magnitude',
line=dict(color='blue', width=2)
name=trace_name,
line=dict(color=line_color, width=2)
))
# Add calibration indicator to title
title_suffix = ' (Calibrated)' if calibrated else ' (Raw)'
fig.update_layout(
title=f'Magnitude Plot - Sweep #{sweep.sweep_number}',
title=f'Magnitude Plot - Sweep #{sweep.sweep_number}{title_suffix}',
xaxis_title='Point Index',
yaxis_title='Magnitude',
width=self.width,
@ -107,10 +149,12 @@ class MagnitudePlotProcessor(BaseSweepProcessor):
return fig
def _save_matplotlib_image(self, sweep: SweepData, magnitude_data: List[Tuple[float, float]]) -> Path | None:
def _save_matplotlib_image(self, sweep: SweepData, magnitude_data: List[Tuple[float, float]], calibrated: bool = False) -> Path | None:
"""Save plot as image file using matplotlib."""
try:
filename = f"magnitude_sweep_{sweep.sweep_number:06d}.{self.image_format}"
# Add calibration indicator to filename
cal_suffix = "_cal" if calibrated else ""
filename = f"magnitude_sweep_{sweep.sweep_number:06d}{cal_suffix}.{self.image_format}"
file_path = self.output_dir / filename
# Extract data for plotting
@ -119,9 +163,16 @@ class MagnitudePlotProcessor(BaseSweepProcessor):
# 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}')
# Choose color and label based on calibration status
line_color = 'green' if calibrated else 'blue'
label = 'Magnitude (Calibrated)' if calibrated else 'Magnitude (Raw)'
ax.plot(indices, magnitudes, color=line_color, linewidth=2, label=label)
# Add calibration indicator to title
title_suffix = ' (Calibrated)' if calibrated else ' (Raw)'
ax.set_title(f'Magnitude Plot - Sweep #{sweep.sweep_number}{title_suffix}')
ax.set_xlabel('Point Index')
ax.set_ylabel('Magnitude')
ax.legend()

View File

@ -0,0 +1,315 @@
from __future__ import annotations
import json
import shutil
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Dict, List
from vna_system.config import config as cfg
from vna_system.core.acquisition.sweep_buffer import SweepData
from .preset_manager import ConfigPreset, VNAMode, PresetManager
class CalibrationStandard(Enum):
OPEN = "open"
SHORT = "short"
LOAD = "load"
THROUGH = "through"
class CalibrationSet:
def __init__(self, preset: ConfigPreset, name: str = ""):
self.preset = preset
self.name = name
self.standards: Dict[CalibrationStandard, SweepData] = {}
def add_standard(self, standard: CalibrationStandard, sweep_data: SweepData):
"""Add calibration data for specific standard"""
self.standards[standard] = sweep_data
def remove_standard(self, standard: CalibrationStandard):
"""Remove calibration data for specific standard"""
if standard in self.standards:
del self.standards[standard]
def has_standard(self, standard: CalibrationStandard) -> bool:
"""Check if standard is present in calibration set"""
return standard in self.standards
def is_complete(self) -> bool:
"""Check if all required standards are present"""
required_standards = self._get_required_standards()
return all(std in self.standards for std in required_standards)
def get_missing_standards(self) -> List[CalibrationStandard]:
"""Get list of missing required standards"""
required_standards = self._get_required_standards()
return [std for std in required_standards if std not in self.standards]
def get_progress(self) -> tuple[int, int]:
"""Get calibration progress as (completed, total)"""
required_standards = self._get_required_standards()
completed = len([std for std in required_standards if std in self.standards])
return completed, len(required_standards)
def _get_required_standards(self) -> List[CalibrationStandard]:
"""Get required calibration standards for preset mode"""
if self.preset.mode == VNAMode.S11:
return [CalibrationStandard.OPEN, CalibrationStandard.SHORT, CalibrationStandard.LOAD]
elif self.preset.mode == VNAMode.S21:
return [CalibrationStandard.THROUGH]
return []
class CalibrationManager:
def __init__(self, preset_manager: PresetManager, base_dir: Path | None = None):
self.base_dir = Path(base_dir or cfg.BASE_DIR)
self.calibration_dir = self.base_dir / "calibration"
self.current_calibration_symlink = self.calibration_dir / "current_calibration"
self.calibration_dir.mkdir(parents=True, exist_ok=True)
# Current working calibration set
self._current_working_set: CalibrationSet | None = None
# Preset manager for parsing filenames
self.preset_manager = preset_manager
def start_new_calibration(self, preset: ConfigPreset) -> CalibrationSet:
"""Start new calibration set for preset"""
self._current_working_set = CalibrationSet(preset)
return self._current_working_set
def get_current_working_set(self) -> CalibrationSet | None:
"""Get current working calibration set"""
return self._current_working_set
def add_calibration_standard(self, standard: CalibrationStandard, sweep_data: SweepData):
"""Add calibration standard to current working set"""
if self._current_working_set is None:
raise RuntimeError("No active calibration set. Call start_new_calibration first.")
self._current_working_set.add_standard(standard, sweep_data)
def remove_calibration_standard(self, standard: CalibrationStandard):
"""Remove calibration standard from current working set"""
if self._current_working_set is None:
raise RuntimeError("No active calibration set.")
self._current_working_set.remove_standard(standard)
def save_calibration_set(self, calibration_name: str) -> CalibrationSet:
"""Save current working calibration set to disk"""
if self._current_working_set is None:
raise RuntimeError("No active calibration set to save.")
if not self._current_working_set.is_complete():
missing = self._current_working_set.get_missing_standards()
raise ValueError(f"Calibration incomplete. Missing standards: {[s.value for s in missing]}")
preset = self._current_working_set.preset
preset_dir = self._get_preset_calibration_dir(preset)
calib_dir = preset_dir / calibration_name
calib_dir.mkdir(parents=True, exist_ok=True)
# Save each standard
for standard, sweep_data in self._current_working_set.standards.items():
# Save sweep data as JSON
sweep_json = {
'sweep_number': sweep_data.sweep_number,
'timestamp': sweep_data.timestamp,
'points': sweep_data.points,
'total_points': sweep_data.total_points
}
file_path = calib_dir / f"{standard.value}.json"
with open(file_path, 'w') as f:
json.dump(sweep_json, f, indent=2)
# Save metadata for each standard
metadata = {
'preset': {
'filename': preset.filename,
'mode': preset.mode.value,
'start_freq': preset.start_freq,
'stop_freq': preset.stop_freq,
'points': preset.points,
'bandwidth': preset.bandwidth
},
'calibration_name': calibration_name,
'standard': standard.value,
'sweep_number': sweep_data.sweep_number,
'sweep_timestamp': sweep_data.timestamp,
'created_timestamp': datetime.now().isoformat(),
'total_points': sweep_data.total_points
}
metadata_path = calib_dir / f"{standard.value}_metadata.json"
with open(metadata_path, 'w') as f:
json.dump(metadata, f, indent=2)
# Save calibration set metadata
set_metadata = {
'preset': {
'filename': preset.filename,
'mode': preset.mode.value,
'start_freq': preset.start_freq,
'stop_freq': preset.stop_freq,
'points': preset.points,
'bandwidth': preset.bandwidth
},
'calibration_name': calibration_name,
'standards': [std.value for std in self._current_working_set.standards.keys()],
'created_timestamp': datetime.now().isoformat(),
'is_complete': True
}
set_metadata_path = calib_dir / "calibration_info.json"
with open(set_metadata_path, 'w') as f:
json.dump(set_metadata, f, indent=2)
# Set name for the working set
self._current_working_set.name = calibration_name
return self._current_working_set
def load_calibration_set(self, preset: ConfigPreset, calibration_name: str) -> CalibrationSet:
"""Load existing calibration set from disk"""
preset_dir = self._get_preset_calibration_dir(preset)
calib_dir = preset_dir / calibration_name
if not calib_dir.exists():
raise FileNotFoundError(f"Calibration not found: {calibration_name}")
calib_set = CalibrationSet(preset, calibration_name)
# Load all standard files
for standard in CalibrationStandard:
file_path = calib_dir / f"{standard.value}.json"
if file_path.exists():
with open(file_path, 'r') as f:
sweep_json = json.load(f)
sweep_data = SweepData(
sweep_number=sweep_json['sweep_number'],
timestamp=sweep_json['timestamp'],
points=sweep_json['points'],
total_points=sweep_json['total_points']
)
calib_set.add_standard(standard, sweep_data)
return calib_set
def get_available_calibrations(self, preset: ConfigPreset) -> List[str]:
"""Get list of available calibration sets for preset"""
preset_dir = self._get_preset_calibration_dir(preset)
if not preset_dir.exists():
return []
calibrations = []
for item in preset_dir.iterdir():
if item.is_dir():
calibrations.append(item.name)
return sorted(calibrations)
def get_calibration_info(self, preset: ConfigPreset, calibration_name: str) -> Dict:
"""Get information about specific calibration"""
preset_dir = self._get_preset_calibration_dir(preset)
calib_dir = preset_dir / calibration_name
info_file = calib_dir / "calibration_info.json"
if info_file.exists():
with open(info_file, 'r') as f:
return json.load(f)
# Fallback: scan files
standards = {}
required_standards = self._get_required_standards(preset.mode)
for standard in required_standards:
file_path = calib_dir / f"{standard.value}.json"
standards[standard.value] = file_path.exists()
return {
'calibration_name': calibration_name,
'standards': standards,
'is_complete': all(standards.values())
}
def set_current_calibration(self, preset: ConfigPreset, calibration_name: str):
"""Set current calibration by creating symlink"""
preset_dir = self._get_preset_calibration_dir(preset)
calib_dir = preset_dir / calibration_name
if not calib_dir.exists():
raise FileNotFoundError(f"Calibration not found: {calibration_name}")
# Check if calibration is complete
info = self.get_calibration_info(preset, calibration_name)
if not info.get('is_complete', False):
raise ValueError(f"Calibration {calibration_name} is incomplete")
# Remove existing symlink if present
if self.current_calibration_symlink.exists() or self.current_calibration_symlink.is_symlink():
self.current_calibration_symlink.unlink()
# Create new symlink
try:
relative_path = calib_dir.relative_to(self.calibration_dir)
except ValueError:
relative_path = calib_dir
self.current_calibration_symlink.symlink_to(relative_path)
def get_current_calibration(self) -> CalibrationSet | None:
"""Get currently selected calibration as CalibrationSet"""
if not self.current_calibration_symlink.exists():
return None
try:
target = self.current_calibration_symlink.resolve()
calibration_name = target.name
preset_name = target.parent.name
# Parse preset from filename
preset = self.preset_manager._parse_filename(f"{preset_name}.bin")
if preset is None:
return None
# Load and return the calibration set
return self.load_calibration_set(preset, calibration_name)
except Exception:
return None
def clear_current_calibration(self):
"""Clear current calibration symlink"""
if self.current_calibration_symlink.exists() or self.current_calibration_symlink.is_symlink():
self.current_calibration_symlink.unlink()
def delete_calibration(self, preset: ConfigPreset, calibration_name: str):
"""Delete calibration set"""
preset_dir = self._get_preset_calibration_dir(preset)
calib_dir = preset_dir / calibration_name
if calib_dir.exists():
shutil.rmtree(calib_dir)
def _get_preset_calibration_dir(self, preset: ConfigPreset) -> Path:
"""Get calibration directory for specific preset"""
preset_dir = self.calibration_dir / preset.filename.replace('.bin', '')
preset_dir.mkdir(parents=True, exist_ok=True)
return preset_dir
def _get_required_standards(self, mode: VNAMode) -> List[CalibrationStandard]:
"""Get required calibration standards for VNA mode"""
if mode == VNAMode.S11:
return [CalibrationStandard.OPEN, CalibrationStandard.SHORT, CalibrationStandard.LOAD]
elif mode == VNAMode.S21:
return [CalibrationStandard.THROUGH]
return []

View File

@ -0,0 +1,129 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import List
from vna_system.config import config as cfg
class VNAMode(Enum):
S11 = "s11"
S21 = "s21"
@dataclass
class ConfigPreset:
filename: str
mode: VNAMode
start_freq: float | None = None
stop_freq: float | None = None
points: int | None = None
bandwidth: float | None = None
class PresetManager:
def __init__(self, binary_input_dir: Path | None = None):
self.binary_input_dir = Path(binary_input_dir or cfg.BASE_DIR / "vna_system" / "binary_input")
self.config_inputs_dir = self.binary_input_dir / "config_inputs"
self.current_input_symlink = self.binary_input_dir / "current_input.bin"
self.config_inputs_dir.mkdir(parents=True, exist_ok=True)
def _parse_filename(self, filename: str) -> ConfigPreset | None:
"""Parse configuration parameters from filename like s11_start100_stop8800_points1000_bw1khz.bin"""
base_name = Path(filename).stem.lower()
# Extract mode - must be at the beginning
mode = None
if base_name.startswith('s11'):
mode = VNAMode.S11
elif base_name.startswith('s21'):
mode = VNAMode.S21
else:
return None
preset = ConfigPreset(filename=filename, mode=mode)
# Extract parameters using regex
patterns = {
'start': r'start(\d+(?:\.\d+)?)',
'stop': r'stop(\d+(?:\.\d+)?)',
'points': r'points?(\d+)',
'bw': r'bw(\d+(?:\.\d+)?)(hz|khz|mhz)?'
}
for param, pattern in patterns.items():
match = re.search(pattern, base_name)
if match:
value = float(match.group(1))
if param == 'start':
# Assume MHz if no unit specified
preset.start_freq = value * 1e6
elif param == 'stop':
# Assume MHz if no unit specified
preset.stop_freq = value * 1e6
elif param == 'points':
preset.points = int(value)
elif param == 'bw':
unit = match.group(2) if len(match.groups()) > 1 and match.group(2) else 'hz'
if unit == 'khz':
value *= 1e3
elif unit == 'mhz':
value *= 1e6
# hz is base unit, no multiplication needed
preset.bandwidth = value
return preset
def get_available_presets(self) -> List[ConfigPreset]:
"""Return list of all available configuration presets"""
presets = []
if not self.config_inputs_dir.exists():
return presets
for file_path in self.config_inputs_dir.glob("*.bin"):
preset = self._parse_filename(file_path.name)
if preset is not None:
presets.append(preset)
return sorted(presets, key=lambda x: x.filename)
def set_current_preset(self, preset: ConfigPreset) -> ConfigPreset:
"""Set current configuration by creating symlink to specified preset"""
preset_path = self.config_inputs_dir / preset.filename
if not preset_path.exists():
raise FileNotFoundError(f"Preset file not found: {preset.filename}")
# Remove existing symlink if present
if self.current_input_symlink.exists() or self.current_input_symlink.is_symlink():
self.current_input_symlink.unlink()
# Create new symlink
try:
relative_path = preset_path.relative_to(self.binary_input_dir)
except ValueError:
relative_path = preset_path
self.current_input_symlink.symlink_to(relative_path)
return preset
def get_current_preset(self) -> ConfigPreset | None:
"""Get currently selected configuration preset"""
if not self.current_input_symlink.exists():
return None
try:
target = self.current_input_symlink.resolve()
return self._parse_filename(target.name)
except Exception:
return None
def preset_exists(self, preset: ConfigPreset) -> bool:
"""Check if preset file exists"""
return (self.config_inputs_dir / preset.filename).exists()

View File

@ -1,352 +1,176 @@
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Dict, List, Tuple
from typing import Dict, List
from vna_system.config import config as cfg
from vna_system.core.processing.results_storage import ResultsStorage
from vna_system.core.acquisition.sweep_buffer import SweepData
from .preset_manager import PresetManager, ConfigPreset, VNAMode
from .calibration_manager import CalibrationManager, CalibrationSet, CalibrationStandard
logger = logging.getLogger(__name__)
# ----------------------- Minimal enums & dataclass -----------------------
class VNAMode(Enum):
S11 = "S11"
S21 = "S21"
class CalibrationStandard(Enum):
OPEN = "open"
SHORT = "short"
LOAD = "load"
THROUGH = "through"
@dataclass(frozen=True)
class LogConfig:
"""Parsed configuration from a selected config file."""
mode: VNAMode
file_path: Path
stem: str
start_hz: float | None
stop_hz: float | None
points: int | None
bw_hz: float | None
# ----------------------- Filename parsing helpers --------------------------
_UNIT_MULT = {
"hz": 1.0,
"khz": 1e3,
"mhz": 1e6,
"ghz": 1e9,
}
_PARAM_RE = re.compile(r"^(str|stp|pnts|bw)(?P<val>[0-9]+(?:\.[0-9]+)?)(?P<unit>[a-zA-Z]+)?$")
def _to_hz(val: float, unit: str | None, default_hz: float) -> float:
if unit:
m = _UNIT_MULT.get(unit.lower())
if m:
return float(val) * m
return float(val) * default_hz
def parse_config_filename(name: str, assume_mhz_for_freq: bool = True) -> Tuple[float | None, float | None, int | None, float] | None:
"""
Parse tokens like: str100_stp8800_pnts1000_bw1khz.[bin]
- str/stp default to MHz if no unit (configurable)
- bw defaults to Hz if no unit
"""
base = Path(name).stem
tokens = base.split("_")
start_hz = stop_hz = bw_hz = None
points: int | None = None
for t in tokens:
m = _PARAM_RE.match(t)
if not m:
continue
key = t[:3]
val = float(m.group("val"))
unit = m.group("unit")
if key == "str":
start_hz = _to_hz(val, unit, 1e6 if assume_mhz_for_freq else 1.0)
elif key == "stp":
stop_hz = _to_hz(val, unit, 1e6 if assume_mhz_for_freq else 1.0)
elif key == "pnt": # token 'pnts'
points = int(val)
elif key == "bw":
bw_hz = _to_hz(val, unit, 1.0)
return start_hz, stop_hz, points, bw_hz
# ----------------------- VNA Settings Manager ------------------------------
class VNASettingsManager:
"""
- Scans config_logs/{S11,S21}/ for available configs
- Controls current_log.bin symlink (must be a real symlink)
- Parses config params from filename
- Stores per-config calibration in:
calibration/<MODE>/<STEM>/<STANDARD>/<timestamp>_sweepNNNNNN/
- copies ALL processor result JSON files for that sweep (and metadata.json if present)
- UI helpers: select S11/S21, calibrate (through/open/short/load) by sweep number
Main settings manager that coordinates preset and calibration management.
Provides high-level interface for:
- Managing configuration presets
- Managing calibration data
- Coordinating between preset selection and calibration
"""
def __init__(
self,
base_dir: Path | None = None,
config_logs_subdir: str = "binary_logs/config_logs",
current_log_name: str = "current_log.bin",
calibration_subdir: str = "calibration",
assume_mhz_for_freq: bool = True,
results_storage: ResultsStorage | None = None,
):
def __init__(self, base_dir: Path | None = None):
self.base_dir = Path(base_dir or cfg.BASE_DIR)
self.cfg_logs_dir = self.base_dir / config_logs_subdir
self.current_log = self.cfg_logs_dir / current_log_name
self.calib_root = self.base_dir / calibration_subdir
self.assume_mhz_for_freq = assume_mhz_for_freq
# Ensure directory structure exists
(self.cfg_logs_dir / "S11").mkdir(parents=True, exist_ok=True)
(self.cfg_logs_dir / "S21").mkdir(parents=True, exist_ok=True)
self.calib_root.mkdir(parents=True, exist_ok=True)
# Initialize sub-managers
self.preset_manager = PresetManager(self.base_dir / "binary_input")
self.calibration_manager = CalibrationManager(self.preset_manager, self.base_dir)
# Results storage
self.results = results_storage or ResultsStorage(
storage_dir=str(self.base_dir / "processing_results")
)
# ---------- Preset Management ----------
# ---------- configuration selection & discovery ----------
def get_available_presets(self) -> List[ConfigPreset]:
"""Get all available configuration presets"""
return self.preset_manager.get_available_presets()
def list_configs(self, mode: VNAMode | None = None) -> List[LogConfig]:
modes = [mode] if mode else [VNAMode.S11, VNAMode.S21]
out: List[LogConfig] = []
for m in modes:
d = self.cfg_logs_dir / m.value
if not d.exists():
continue
for fp in sorted(d.glob("*.bin")):
s, e, n, bw = parse_config_filename(fp.name, self.assume_mhz_for_freq)
out.append(LogConfig(
mode=m,
file_path=fp.resolve(),
stem=fp.stem,
start_hz=s,
stop_hz=e,
points=n,
bw_hz=bw,
))
return out
def get_presets_by_mode(self, mode: VNAMode) -> List[ConfigPreset]:
"""Get presets filtered by VNA mode"""
all_presets = self.get_available_presets()
return [p for p in all_presets if p.mode == mode]
def set_current_config(self, mode: VNAMode, filename: str) -> LogConfig:
"""
Update current_log.bin symlink to point to config_logs/<MODE>/<filename>.
Real symlink only; will raise if not supported.
"""
target = (self.cfg_logs_dir / mode.value / filename).resolve()
if not target.exists():
raise FileNotFoundError(f"Config not found: {target}")
def set_current_preset(self, preset: ConfigPreset) -> ConfigPreset:
"""Set current configuration preset"""
return self.preset_manager.set_current_preset(preset)
if self.current_log.exists() or self.current_log.is_symlink():
self.current_log.unlink()
def get_current_preset(self) -> ConfigPreset | None:
"""Get currently selected preset"""
return self.preset_manager.get_current_preset()
# relative link if possible, else absolute (still a symlink)
try:
rel = target.relative_to(self.current_log.parent)
except ValueError:
rel = target
# ---------- Calibration Management ----------
self.current_log.symlink_to(rel)
return self.get_current_config()
def start_new_calibration(self, preset: ConfigPreset | None = None) -> CalibrationSet:
"""Start new calibration for current or specified preset"""
if preset is None:
preset = self.get_current_preset()
if preset is None:
raise RuntimeError("No current preset selected")
def get_current_config(self) -> LogConfig:
if not self.current_log.exists():
raise FileNotFoundError(f"{self.current_log} does not exist")
return self.calibration_manager.start_new_calibration(preset)
tgt = self.current_log.resolve()
mode = VNAMode(tgt.parent.name) # expects .../config_logs/<MODE>/<file>
s, e, n, bw = parse_config_filename(tgt.name, self.assume_mhz_for_freq)
return LogConfig(
mode=mode,
file_path=tgt,
stem=tgt.stem,
start_hz=s, stop_hz=e, points=n, bw_hz=bw
)
# ---------- calibration capture (ALL processors) ----------
def get_current_working_calibration(self) -> CalibrationSet | None:
"""Get current working calibration set (in-progress, not yet saved)"""
return self.calibration_manager.get_current_working_set()
@staticmethod
def required_standards(mode: VNAMode) -> List[CalibrationStandard]:
return (
[CalibrationStandard.OPEN, CalibrationStandard.SHORT, CalibrationStandard.LOAD]
if mode == VNAMode.S11
else [CalibrationStandard.THROUGH]
)
def add_calibration_standard(self, standard: CalibrationStandard, sweep_data: SweepData):
"""Add calibration standard to current working set"""
self.calibration_manager.add_calibration_standard(standard, sweep_data)
def _calib_dir(self, cfg: LogConfig, standard: CalibrationStandard | None = None) -> Path:
base = self.calib_root / cfg.mode.value / cfg.stem
return base / standard.value if standard else base
def remove_calibration_standard(self, standard: CalibrationStandard):
"""Remove calibration standard from current working set"""
self.calibration_manager.remove_calibration_standard(standard)
def _calib_sweep_dir(self, cfg: LogConfig, standard: CalibrationStandard, sweep_number: int, ts: str | None = None) -> Path:
"""
calibration/<MODE>/<STEM>/<STANDARD>/<timestamp>_sweepNNNNNN/
"""
ts = ts or datetime.now().strftime("%Y%m%d_%H%M%S")
d = self._calib_dir(cfg, standard) / f"{ts}_sweep{sweep_number:06d}"
d.mkdir(parents=True, exist_ok=True)
return d
def save_calibration_set(self, calibration_name: str) -> CalibrationSet:
"""Save current working calibration set"""
return self.calibration_manager.save_calibration_set(calibration_name)
def record_calibration_from_sweep(
self,
standard: CalibrationStandard,
sweep_number: int,
*,
cfg: LogConfig | None = None
) -> Path:
"""
Capture ALL processor JSON results for the given sweep and save under:
calibration/<MODE>/<STEM>/<STANDARD>/<timestamp>_sweepNNNNNN/
Also copy metadata.json if available.
Returns the created sweep calibration directory.
"""
cfg = cfg or self.get_current_config()
def get_available_calibrations(self, preset: ConfigPreset | None = None) -> List[str]:
"""Get available calibrations for current or specified preset"""
if preset is None:
preset = self.get_current_preset()
if preset is None:
return []
# Get ALL results for the sweep
results = self.results.get_result_by_sweep(sweep_number, processor_name=None)
if not results:
raise FileNotFoundError(f"No processor results found for sweep {sweep_number}")
return self.calibration_manager.get_available_calibrations(preset)
# Determine destination dir
dst_dir = self._calib_sweep_dir(cfg, standard, sweep_number)
def set_current_calibration(self, calibration_name: str, preset: ConfigPreset | None = None):
"""Set current calibration"""
if preset is None:
preset = self.get_current_preset()
if preset is None:
raise RuntimeError("No current preset selected")
# Save processor files (re-serialize what ResultsStorage returns)
count = 0
for r in results:
try:
dst_file = dst_dir / f"{r.processor_name}.json"
payload = {
"processor_name": r.processor_name,
"sweep_number": r.sweep_number,
"data": r.data,
}
# keep optional fields if present
if getattr(r, "plotly_figure", None) is not None:
payload["plotly_figure"] = r.plotly_figure
if getattr(r, "file_path", None) is not None:
payload["file_path"] = r.file_path
self.calibration_manager.set_current_calibration(preset, calibration_name)
with open(dst_file, "w") as f:
json.dump(payload, f, indent=2)
count += 1
except Exception as e:
logger.error(f"Failed to store processor '{r.processor_name}' for sweep {sweep_number}: {e}")
def get_current_calibration(self) -> CalibrationSet | None:
"""Get currently selected calibration set (saved and active via symlink)"""
return self.calibration_manager.get_current_calibration()
# Save metadata if available
try:
meta = self.results.get_sweep_metadata(sweep_number)
if meta:
with open(dst_dir / "metadata.json", "w") as f:
json.dump(meta, f, indent=2)
except Exception as e:
logger.warning(f"Failed to write metadata for sweep {sweep_number}: {e}")
def get_calibration_info(self, calibration_name: str, preset: ConfigPreset | None = None) -> Dict:
"""Get calibration information"""
if preset is None:
preset = self.get_current_preset()
if preset is None:
raise RuntimeError("No current preset selected")
if count == 0:
raise RuntimeError(f"Nothing was written for sweep {sweep_number}")
return self.calibration_manager.get_calibration_info(preset, calibration_name)
logger.info(f"Stored calibration (standard={standard.value}) from sweep {sweep_number} into {dst_dir}")
return dst_dir
# ---------- Combined Status and UI helpers ----------
def latest_calibration(self, cfg: LogConfig | None = None) -> Dict[CalibrationStandard, Path] | None:
"""
Returns the latest sweep directory per required standard for the current (or provided) config.
"""
cfg = cfg or self.get_current_config()
out: Dict[CalibrationStandard, Path] | None = {}
for std in self.required_standards(cfg.mode):
d = self._calib_dir(cfg, std)
if not d.exists():
out[std] = None
continue
subdirs = sorted([p for p in d.iterdir() if p.is_dir()])
out[std] = subdirs[-1] if subdirs else None
return out
def get_status_summary(self) -> Dict[str, object]:
"""Get comprehensive status of current configuration and calibration"""
current_preset = self.get_current_preset()
current_calibration = self.get_current_calibration()
working_calibration = self.get_current_working_calibration()
def calibration_status(self, cfg: LogConfig | None = None) -> Dict[str, bool]:
cfg = cfg or self.get_current_config()
latest = self.latest_calibration(cfg)
return {std.value: (p is not None and p.exists()) for std, p in latest.items()}
def is_fully_calibrated(self, cfg: LogConfig | None = None) -> bool:
return all(self.calibration_status(cfg).values())
# ---------- UI helpers ----------
def summary(self) -> Dict[str, object]:
cfg = self.get_current_config()
latest = self.latest_calibration(cfg)
return {
"mode": cfg.mode.value,
"current_log": str(self.current_log),
"selected_file": str(cfg.file_path),
"stem": cfg.stem,
"params": {
"start_hz": cfg.start_hz,
"stop_hz": cfg.stop_hz,
"points": cfg.points,
"bw_hz": cfg.bw_hz,
},
"required_standards": [s.value for s in self.required_standards(cfg.mode)],
"calibration_latest": {k.value: (str(v) if v else None) for k, v in latest.items()},
"is_fully_calibrated": self.is_fully_calibrated(cfg),
summary = {
"current_preset": None,
"current_calibration": None,
"working_calibration": None,
"available_presets": len(self.get_available_presets()),
"available_calibrations": 0
}
def ui_select_S11(self, filename: str) -> Dict[str, object]:
self.set_current_config(VNAMode.S11, filename)
return self.summary()
if current_preset:
summary["current_preset"] = {
"filename": current_preset.filename,
"mode": current_preset.mode.value,
"start_freq": current_preset.start_freq,
"stop_freq": current_preset.stop_freq,
"points": current_preset.points,
"bandwidth": current_preset.bandwidth
}
summary["available_calibrations"] = len(self.get_available_calibrations(current_preset))
def ui_select_S21(self, filename: str) -> Dict[str, object]:
self.set_current_config(VNAMode.S21, filename)
return self.summary()
if current_calibration:
summary["current_calibration"] = {
"preset_filename": current_calibration.preset.filename,
"calibration_name": current_calibration.name
}
# Calibration triggers (buttons)
def ui_calibrate_through(self, sweep_number: int) -> Dict[str, object]:
cfg = self.get_current_config()
if cfg.mode != VNAMode.S21:
raise RuntimeError("THROUGH is only valid in S21 mode")
self.record_calibration_from_sweep(CalibrationStandard.THROUGH, sweep_number)
return self.summary()
if working_calibration:
completed, total = working_calibration.get_progress()
summary["working_calibration"] = {
"preset_filename": working_calibration.preset.filename,
"progress": f"{completed}/{total}",
"is_complete": working_calibration.is_complete(),
"missing_standards": [s.value for s in working_calibration.get_missing_standards()]
}
def ui_calibrate_open(self, sweep_number: int) -> Dict[str, object]:
cfg = self.get_current_config()
if cfg.mode != VNAMode.S11:
raise RuntimeError("OPEN is only valid in S11 mode")
self.record_calibration_from_sweep(CalibrationStandard.OPEN, sweep_number)
return self.summary()
return summary
def ui_calibrate_short(self, sweep_number: int) -> Dict[str, object]:
cfg = self.get_current_config()
if cfg.mode != VNAMode.S11:
raise RuntimeError("SHORT is only valid in S11 mode")
self.record_calibration_from_sweep(CalibrationStandard.SHORT, sweep_number)
return self.summary()
@staticmethod
def get_required_standards(mode: VNAMode) -> List[CalibrationStandard]:
"""Get required calibration standards for VNA mode"""
if mode == VNAMode.S11:
return [CalibrationStandard.OPEN, CalibrationStandard.SHORT, CalibrationStandard.LOAD]
elif mode == VNAMode.S21:
return [CalibrationStandard.THROUGH]
return []
def ui_calibrate_load(self, sweep_number: int) -> Dict[str, object]:
cfg = self.get_current_config()
if cfg.mode != VNAMode.S11:
raise RuntimeError("LOAD is only valid in S11 mode")
self.record_calibration_from_sweep(CalibrationStandard.LOAD, sweep_number)
return self.summary()
# ---------- Integration with VNADataAcquisition ----------
def capture_calibration_standard_from_acquisition(self, standard: CalibrationStandard, data_acquisition):
"""Capture calibration standard from VNADataAcquisition instance"""
# Get latest sweep from acquisition
latest_sweep = data_acquisition._sweep_buffer.get_latest_sweep()
if latest_sweep is None:
raise RuntimeError("No sweep data available in acquisition buffer")
# Add to current working calibration
self.add_calibration_standard(standard, latest_sweep)
logger.info(f"Captured {standard.value} calibration standard from sweep {latest_sweep.sweep_number}")
return latest_sweep.sweep_number

View File

@ -0,0 +1,492 @@
/* ===================================================================
Settings Page Styles - Professional Design
=================================================================== */
/* Settings Container */
.settings-container {
max-width: var(--max-content-width);
margin: 0 auto;
padding: var(--space-8);
background: var(--bg-primary);
min-height: calc(100vh - var(--header-height));
}
.settings-section {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-8);
}
.settings-title {
display: flex;
align-items: center;
gap: var(--space-4);
color: var(--text-primary);
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
margin: 0 0 var(--space-8) 0;
text-shadow: none;
}
.settings-icon {
width: 2.5rem;
height: 2.5rem;
color: var(--color-primary-500);
}
/* Professional Cards */
.settings-card {
background: var(--bg-surface);
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
padding: var(--space-8);
box-shadow: var(--shadow-lg);
position: relative;
transition: all var(--transition-normal);
}
.settings-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--color-primary-500), var(--color-primary-600));
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
}
.settings-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-xl);
border-color: var(--border-secondary);
}
.settings-card-title {
color: var(--text-primary);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
margin: 0 0 var(--space-3) 0;
display: flex;
align-items: center;
gap: var(--space-3);
}
.settings-card-title::before {
content: '';
width: 6px;
height: 24px;
background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-600));
border-radius: 3px;
}
.settings-card-description {
color: var(--text-secondary);
font-size: var(--font-size-base);
margin: 0 0 var(--space-8) 0;
line-height: var(--line-height-relaxed);
}
/* Enhanced Preset Controls */
.preset-controls {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.control-group {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-6);
transition: all var(--transition-fast);
}
.control-group:hover {
border-color: var(--border-secondary);
background: var(--bg-surface-hover);
}
.control-label {
color: var(--text-primary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-base);
margin-bottom: var(--space-4);
display: flex;
align-items: center;
gap: var(--space-3);
}
.control-label::before {
content: '';
width: 4px;
height: 4px;
background: var(--color-primary-500);
border-radius: 50%;
}
.preset-selector {
display: flex;
gap: var(--space-4);
align-items: stretch;
}
.preset-dropdown,
.calibration-dropdown {
flex: 1;
padding: var(--space-3) var(--space-4);
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
color: var(--text-primary);
font-size: var(--font-size-sm);
min-height: 3rem;
font-weight: var(--font-weight-medium);
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.preset-dropdown:focus,
.calibration-dropdown:focus {
outline: none;
border-color: var(--color-primary-500);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
background: var(--bg-surface);
}
.preset-dropdown:hover,
.calibration-dropdown:hover {
border-color: var(--color-primary-500);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.preset-dropdown:disabled,
.calibration-dropdown:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.preset-info {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.preset-details {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
}
.preset-param {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.param-label {
color: var(--text-secondary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
.param-value {
color: var(--text-primary);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
}
/* Calibration Status */
.calibration-status {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-6);
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-2) 0;
}
.status-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.status-value {
color: var(--text-primary);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
}
/* Calibration Workflow */
.calibration-workflow {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.workflow-section {
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-6);
background: var(--bg-surface);
transition: all var(--transition-fast);
}
.workflow-section:hover {
border-color: var(--border-secondary);
}
.workflow-title {
color: var(--text-primary);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
margin: 0 0 var(--space-4) 0;
}
.workflow-controls {
display: flex;
gap: var(--space-3);
align-items: center;
}
/* Calibration Progress */
.calibration-progress {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-4);
}
.progress-info {
text-align: center;
}
.progress-text {
color: var(--text-primary);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
font-family: var(--font-mono);
}
/* Calibration Standards */
.calibration-standards {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.calibration-standard-btn {
min-width: 120px;
white-space: nowrap;
}
.calibration-standard-btn i {
margin-right: var(--space-2);
}
.calibration-standard-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Calibration Actions */
.calibration-actions {
display: flex;
gap: var(--space-3);
align-items: stretch;
margin-top: var(--space-4);
}
.calibration-name-input {
flex: 1;
padding: var(--space-3) var(--space-4);
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
color: var(--text-primary);
font-size: var(--font-size-sm);
min-height: 2.5rem;
transition: all var(--transition-fast);
}
.calibration-name-input:focus {
outline: none;
border-color: var(--color-primary-500);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.calibration-name-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.calibration-name-input::placeholder {
color: var(--text-tertiary);
}
/* Existing Calibrations */
.existing-calibrations {
display: flex;
gap: var(--space-3);
align-items: stretch;
}
/* Status Grid */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-4);
}
.status-grid .status-item {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-1);
transition: all var(--transition-fast);
}
.status-grid .status-item:hover {
background: var(--bg-surface-hover);
border-color: var(--border-secondary);
}
.status-grid .status-label {
font-size: var(--font-size-xs);
margin: 0;
}
.status-grid .status-value {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
margin: 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.settings-container {
padding: var(--spacing-md);
}
.preset-selector {
flex-direction: column;
}
.calibration-actions {
flex-direction: column;
}
.existing-calibrations {
flex-direction: column;
}
.status-grid {
grid-template-columns: 1fr;
}
.calibration-standards {
flex-direction: column;
}
.calibration-standard-btn {
min-width: auto;
}
}
/* Animation and Transitions */
.settings-card {
transition: box-shadow 0.2s ease;
}
.settings-card:hover {
box-shadow: var(--shadow-md);
}
.workflow-section {
transition: border-color 0.2s ease;
}
.workflow-section:hover {
border-color: var(--color-primary-alpha);
}
/* Loading States */
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
.btn.loading {
position: relative;
color: transparent;
}
.btn.loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Success/Error States */
.preset-param .param-value.success {
color: var(--color-success-500);
}
.preset-param .param-value.error {
color: var(--color-error-500);
}
.preset-param .param-value.warning {
color: var(--color-warning-500);
}
/* Calibration standard button states */
.calibration-standard-btn.btn--success {
background-color: var(--color-success-500);
border-color: var(--color-success-500);
color: white;
}
.calibration-standard-btn.btn--success:hover:not(:disabled) {
background-color: var(--color-success-600);
border-color: var(--color-success-600);
}
.calibration-standard-btn.btn--warning {
background-color: var(--color-warning-500);
border-color: var(--color-warning-500);
color: white;
}
.calibration-standard-btn.btn--warning:hover:not(:disabled) {
background-color: var(--color-warning-600);
border-color: var(--color-warning-600);
}

View File

@ -8,6 +8,7 @@ import { ChartManager } from './modules/charts.js';
import { UIManager } from './modules/ui.js';
import { NotificationManager } from './modules/notifications.js';
import { StorageManager } from './modules/storage.js';
import { SettingsManager } from './modules/settings.js';
/**
* Main Application Class
@ -40,6 +41,7 @@ class VNADashboard {
this.ui = new UIManager(this.notifications);
this.charts = new ChartManager(this.config.charts, this.notifications);
this.websocket = new WebSocketManager(this.config.websocket, this.notifications);
this.settings = new SettingsManager(this.notifications);
// Bind methods
this.handleWebSocketData = this.handleWebSocketData.bind(this);
@ -98,6 +100,9 @@ class VNADashboard {
// Initialize chart manager
await this.charts.init();
// Initialize settings manager
await this.settings.init();
// Set up UI event handlers
this.setupUIHandlers();
@ -144,6 +149,11 @@ class VNADashboard {
// Navigation
this.ui.onViewChange((view) => {
console.log(`📱 Switched to view: ${view}`);
// Refresh settings when switching to settings view
if (view === 'settings') {
this.settings.refresh();
}
});
// Processor toggles
@ -347,6 +357,7 @@ class VNADashboard {
// Cleanup managers
this.charts.destroy();
this.settings.destroy();
this.ui.destroy();
this.notifications.destroy();
@ -389,6 +400,7 @@ if (typeof process !== 'undefined' && process?.env?.NODE_ENV === 'development')
dashboard: () => window.vnaDashboard,
websocket: () => window.vnaDashboard?.websocket,
charts: () => window.vnaDashboard?.charts,
ui: () => window.vnaDashboard?.ui
ui: () => window.vnaDashboard?.ui,
settings: () => window.vnaDashboard?.settings
};
}

View File

@ -0,0 +1,585 @@
/**
* Settings Manager Module
* Handles VNA configuration presets and calibration management
*/
export class SettingsManager {
constructor(notifications) {
this.notifications = notifications;
this.isInitialized = false;
this.currentPreset = null;
this.currentCalibration = null;
this.workingCalibration = null;
// DOM elements will be populated during init
this.elements = {};
// Bind methods
this.handlePresetChange = this.handlePresetChange.bind(this);
this.handleSetPreset = this.handleSetPreset.bind(this);
this.handleStartCalibration = this.handleStartCalibration.bind(this);
this.handleCalibrateStandard = this.handleCalibrateStandard.bind(this);
this.handleSaveCalibration = this.handleSaveCalibration.bind(this);
this.handleSetCalibration = this.handleSetCalibration.bind(this);
this.handleCalibrationChange = this.handleCalibrationChange.bind(this);
}
async init() {
try {
console.log('🔧 Initializing Settings Manager...');
// Get DOM elements
this.initializeElements();
// Set up event listeners
this.setupEventListeners();
// Load initial data
await this.loadInitialData();
this.isInitialized = true;
console.log('✅ Settings Manager initialized');
} catch (error) {
console.error('❌ Settings Manager initialization failed:', error);
this.notifications.show({
type: 'error',
title: 'Settings Error',
message: 'Failed to initialize settings'
});
}
}
initializeElements() {
this.elements = {
// Preset elements
presetDropdown: document.getElementById('presetDropdown'),
setPresetBtn: document.getElementById('setPresetBtn'),
currentPreset: document.getElementById('currentPreset'),
// Calibration elements
currentCalibration: document.getElementById('currentCalibration'),
startCalibrationBtn: document.getElementById('startCalibrationBtn'),
calibrationSteps: document.getElementById('calibrationSteps'),
calibrationStandards: document.getElementById('calibrationStandards'),
progressText: document.getElementById('progressText'),
calibrationNameInput: document.getElementById('calibrationNameInput'),
saveCalibrationBtn: document.getElementById('saveCalibrationBtn'),
calibrationDropdown: document.getElementById('calibrationDropdown'),
setCalibrationBtn: document.getElementById('setCalibrationBtn'),
// Status elements
presetCount: document.getElementById('presetCount'),
calibrationCount: document.getElementById('calibrationCount'),
systemStatus: document.getElementById('systemStatus')
};
}
setupEventListeners() {
// Preset controls
this.elements.presetDropdown?.addEventListener('change', this.handlePresetChange);
this.elements.setPresetBtn?.addEventListener('click', this.handleSetPreset);
// Calibration controls
this.elements.startCalibrationBtn?.addEventListener('click', this.handleStartCalibration);
this.elements.saveCalibrationBtn?.addEventListener('click', this.handleSaveCalibration);
this.elements.calibrationDropdown?.addEventListener('change', this.handleCalibrationChange);
this.elements.setCalibrationBtn?.addEventListener('click', this.handleSetCalibration);
// Calibration name input
this.elements.calibrationNameInput?.addEventListener('input', () => {
const hasName = this.elements.calibrationNameInput.value.trim().length > 0;
const isComplete = this.workingCalibration && this.workingCalibration.is_complete;
this.elements.saveCalibrationBtn.disabled = !hasName || !isComplete;
});
}
async loadInitialData() {
await Promise.all([
this.loadPresets(),
this.loadStatus(),
this.loadWorkingCalibration()
]);
}
async loadPresets() {
try {
const response = await fetch('/api/v1/settings/presets');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const presets = await response.json();
this.populatePresetDropdown(presets);
} catch (error) {
console.error('Failed to load presets:', error);
this.notifications.show({
type: 'error',
title: 'Load Error',
message: 'Failed to load configuration presets'
});
}
}
async loadStatus() {
try {
const response = await fetch('/api/v1/settings/status');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const status = await response.json();
this.updateStatusDisplay(status);
} catch (error) {
console.error('Failed to load status:', error);
}
}
async loadWorkingCalibration() {
try {
const response = await fetch('/api/v1/settings/working-calibration');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const workingCalibration = await response.json();
this.updateWorkingCalibration(workingCalibration);
} catch (error) {
console.error('Failed to load working calibration:', error);
}
}
async loadCalibrations() {
if (!this.currentPreset) return;
try {
const response = await fetch(`/api/v1/settings/calibrations?preset_filename=${this.currentPreset.filename}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const calibrations = await response.json();
this.populateCalibrationDropdown(calibrations);
} catch (error) {
console.error('Failed to load calibrations:', error);
}
}
populatePresetDropdown(presets) {
const dropdown = this.elements.presetDropdown;
dropdown.innerHTML = '';
if (presets.length === 0) {
dropdown.innerHTML = '<option value="">No presets available</option>';
dropdown.disabled = true;
this.elements.setPresetBtn.disabled = true;
return;
}
dropdown.innerHTML = '<option value="">Select preset...</option>';
presets.forEach(preset => {
const option = document.createElement('option');
option.value = preset.filename;
option.textContent = this.formatPresetDisplay(preset);
dropdown.appendChild(option);
});
dropdown.disabled = false;
this.elements.setPresetBtn.disabled = true;
}
populateCalibrationDropdown(calibrations) {
const dropdown = this.elements.calibrationDropdown;
dropdown.innerHTML = '';
if (calibrations.length === 0) {
dropdown.innerHTML = '<option value="">No calibrations available</option>';
dropdown.disabled = true;
this.elements.setCalibrationBtn.disabled = true;
return;
}
dropdown.innerHTML = '<option value="">Select calibration...</option>';
calibrations.forEach(calibration => {
const option = document.createElement('option');
option.value = calibration.name;
option.textContent = `${calibration.name} ${calibration.is_complete ? '✓' : '⚠'}`;
dropdown.appendChild(option);
});
dropdown.disabled = false;
this.elements.setCalibrationBtn.disabled = true;
}
formatPresetDisplay(preset) {
let display = `${preset.filename} (${preset.mode})`;
if (preset.start_freq && preset.stop_freq) {
const startMHz = (preset.start_freq / 1e6).toFixed(0);
const stopMHz = (preset.stop_freq / 1e6).toFixed(0);
display += ` - ${startMHz}-${stopMHz}MHz`;
}
if (preset.points) {
display += `, ${preset.points}pts`;
}
return display;
}
updateStatusDisplay(status) {
// Update current preset
if (status.current_preset) {
this.currentPreset = status.current_preset;
this.elements.currentPreset.textContent = status.current_preset.filename;
this.elements.startCalibrationBtn.disabled = false;
// Load calibrations for current preset
this.loadCalibrations();
} else {
this.currentPreset = null;
this.elements.currentPreset.textContent = 'None';
this.elements.startCalibrationBtn.disabled = true;
}
// Update current calibration
if (status.current_calibration) {
this.currentCalibration = status.current_calibration;
this.elements.currentCalibration.textContent = status.current_calibration.calibration_name;
} else {
this.currentCalibration = null;
this.elements.currentCalibration.textContent = 'None';
}
// Update counts
this.elements.presetCount.textContent = status.available_presets || 0;
this.elements.calibrationCount.textContent = status.available_calibrations || 0;
this.elements.systemStatus.textContent = 'Ready';
}
updateWorkingCalibration(workingCalibration) {
this.workingCalibration = workingCalibration;
if (workingCalibration.active) {
this.showCalibrationSteps(workingCalibration);
} else {
this.hideCalibrationSteps();
}
}
showCalibrationSteps(workingCalibration) {
this.elements.calibrationSteps.style.display = 'block';
this.elements.progressText.textContent = workingCalibration.progress || '0/0';
// Generate standard buttons
this.generateStandardButtons(workingCalibration);
// Update save button state
const hasName = this.elements.calibrationNameInput.value.trim().length > 0;
this.elements.saveCalibrationBtn.disabled = !hasName || !workingCalibration.is_complete;
this.elements.calibrationNameInput.disabled = false;
}
hideCalibrationSteps() {
this.elements.calibrationSteps.style.display = 'none';
this.elements.calibrationStandards.innerHTML = '';
}
generateStandardButtons(workingCalibration) {
const container = this.elements.calibrationStandards;
container.innerHTML = '';
const allStandards = this.getAllStandardsForMode();
const completedStandards = workingCalibration.completed_standards || [];
const missingStandards = workingCalibration.missing_standards || [];
allStandards.forEach(standard => {
const button = document.createElement('button');
button.className = 'btn calibration-standard-btn';
button.dataset.standard = standard;
const isCompleted = completedStandards.includes(standard);
const isMissing = missingStandards.includes(standard);
if (isCompleted) {
button.classList.add('btn--success');
button.innerHTML = `<i data-lucide="check"></i> ${standard.toUpperCase()}`;
button.disabled = false;
button.title = 'Click to recapture this standard';
} else if (isMissing) {
button.classList.add('btn--primary');
button.innerHTML = `<i data-lucide="radio"></i> Capture ${standard.toUpperCase()}`;
button.disabled = false;
button.title = 'Click to capture this standard';
} else {
button.classList.add('btn--secondary');
button.innerHTML = `${standard.toUpperCase()}`;
button.disabled = true;
}
button.addEventListener('click', () => this.handleCalibrateStandard(standard));
container.appendChild(button);
});
// Re-initialize lucide icons for new buttons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
getAllStandardsForMode() {
if (!this.currentPreset) return [];
if (this.currentPreset.mode === 's11') {
return ['open', 'short', 'load'];
} else if (this.currentPreset.mode === 's21') {
return ['through'];
}
return [];
}
// Event handlers
handlePresetChange() {
const selectedValue = this.elements.presetDropdown.value;
this.elements.setPresetBtn.disabled = !selectedValue;
}
async handleSetPreset() {
const filename = this.elements.presetDropdown.value;
if (!filename) return;
try {
this.elements.setPresetBtn.disabled = true;
this.elements.setPresetBtn.textContent = 'Setting...';
const response = await fetch('/api/v1/settings/preset/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
this.notifications.show({
type: 'success',
title: 'Preset Set',
message: result.message
});
// Reload status
await this.loadStatus();
} catch (error) {
console.error('Failed to set preset:', error);
this.notifications.show({
type: 'error',
title: 'Preset Error',
message: 'Failed to set configuration preset'
});
} finally {
this.elements.setPresetBtn.disabled = false;
this.elements.setPresetBtn.innerHTML = '<i data-lucide="check"></i> Set Active';
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
async handleStartCalibration() {
if (!this.currentPreset) return;
try {
this.elements.startCalibrationBtn.disabled = true;
this.elements.startCalibrationBtn.textContent = 'Starting...';
const response = await fetch('/api/v1/settings/calibration/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preset_filename: this.currentPreset.filename })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
this.notifications.show({
type: 'info',
title: 'Calibration Started',
message: `Started calibration for ${result.preset}`
});
// Reload working calibration
await this.loadWorkingCalibration();
} catch (error) {
console.error('Failed to start calibration:', error);
this.notifications.show({
type: 'error',
title: 'Calibration Error',
message: 'Failed to start calibration'
});
} finally {
this.elements.startCalibrationBtn.disabled = false;
this.elements.startCalibrationBtn.innerHTML = '<i data-lucide="play"></i> Start Calibration';
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
async handleCalibrateStandard(standard) {
try {
const button = document.querySelector(`[data-standard="${standard}"]`);
if (button) {
button.disabled = true;
button.textContent = 'Capturing...';
}
const response = await fetch('/api/v1/settings/calibration/add-standard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ standard })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
this.notifications.show({
type: 'success',
title: 'Standard Captured',
message: result.message
});
// Reload working calibration
await this.loadWorkingCalibration();
} catch (error) {
console.error('Failed to capture standard:', error);
this.notifications.show({
type: 'error',
title: 'Calibration Error',
message: 'Failed to capture calibration standard'
});
// Re-enable button
const button = document.querySelector(`[data-standard="${standard}"]`);
if (button) {
button.disabled = false;
this.generateStandardButtons(this.workingCalibration);
}
}
}
async handleSaveCalibration() {
const name = this.elements.calibrationNameInput.value.trim();
if (!name) return;
try {
this.elements.saveCalibrationBtn.disabled = true;
this.elements.saveCalibrationBtn.textContent = 'Saving...';
const response = await fetch('/api/v1/settings/calibration/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
this.notifications.show({
type: 'success',
title: 'Calibration Saved',
message: result.message
});
// Clear working calibration
this.hideCalibrationSteps();
this.elements.calibrationNameInput.value = '';
// Reload data
await Promise.all([
this.loadStatus(),
this.loadWorkingCalibration(),
this.loadCalibrations()
]);
} catch (error) {
console.error('Failed to save calibration:', error);
this.notifications.show({
type: 'error',
title: 'Calibration Error',
message: 'Failed to save calibration'
});
} finally {
this.elements.saveCalibrationBtn.disabled = true;
this.elements.saveCalibrationBtn.innerHTML = '<i data-lucide="save"></i> Save Calibration';
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
handleCalibrationChange() {
const selectedValue = this.elements.calibrationDropdown.value;
this.elements.setCalibrationBtn.disabled = !selectedValue;
}
async handleSetCalibration() {
const name = this.elements.calibrationDropdown.value;
if (!name || !this.currentPreset) return;
try {
this.elements.setCalibrationBtn.disabled = true;
this.elements.setCalibrationBtn.textContent = 'Setting...';
const response = await fetch('/api/v1/settings/calibration/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
preset_filename: this.currentPreset.filename
})
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
this.notifications.show({
type: 'success',
title: 'Calibration Set',
message: result.message
});
// Reload status
await this.loadStatus();
} catch (error) {
console.error('Failed to set calibration:', error);
this.notifications.show({
type: 'error',
title: 'Calibration Error',
message: 'Failed to set active calibration'
});
} finally {
this.elements.setCalibrationBtn.disabled = false;
this.elements.setCalibrationBtn.innerHTML = '<i data-lucide="check"></i> Set Active';
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
// Public methods for external use
async refresh() {
if (!this.isInitialized) return;
await this.loadInitialData();
}
destroy() {
// Remove event listeners
this.elements.presetDropdown?.removeEventListener('change', this.handlePresetChange);
this.elements.setPresetBtn?.removeEventListener('click', this.handleSetPreset);
this.elements.startCalibrationBtn?.removeEventListener('click', this.handleStartCalibration);
this.elements.saveCalibrationBtn?.removeEventListener('click', this.handleSaveCalibration);
this.elements.calibrationDropdown?.removeEventListener('change', this.handleCalibrationChange);
this.elements.setCalibrationBtn?.removeEventListener('click', this.handleSetCalibration);
this.isInitialized = false;
console.log('🧹 Settings Manager destroyed');
}
}

View File

@ -31,6 +31,7 @@
<link rel="stylesheet" href="/static/css/layout.css">
<link rel="stylesheet" href="/static/css/components.css">
<link rel="stylesheet" href="/static/css/charts.css">
<link rel="stylesheet" href="/static/css/settings.css">
</head>
<body>
<!-- Header -->
@ -123,8 +124,125 @@
<!-- Settings View -->
<div class="view" id="settingsView">
<div class="settings-container">
<h2>Settings</h2>
<p>Settings panel will be implemented in future versions.</p>
<div class="settings-section">
<h2 class="settings-title">
<i data-lucide="settings" class="settings-icon"></i>
VNA Settings
</h2>
<!-- Configuration Presets Section -->
<div class="settings-card">
<h3 class="settings-card-title">Configuration Presets</h3>
<p class="settings-card-description">Select measurement configuration from available presets</p>
<div class="preset-controls">
<div class="control-group">
<label class="control-label">Available Presets:</label>
<div class="preset-selector" id="presetSelector">
<select class="preset-dropdown" id="presetDropdown" disabled>
<option value="">Loading presets...</option>
</select>
<button class="btn btn--primary" id="setPresetBtn" disabled>
<i data-lucide="check"></i>
Set Active
</button>
</div>
</div>
<div class="preset-info" id="presetInfo">
<div class="preset-details">
<div class="preset-param">
<span class="param-label">Current:</span>
<span class="param-value" id="currentPreset">None</span>
</div>
</div>
</div>
</div>
</div>
<!-- Calibration Section -->
<div class="settings-card">
<h3 class="settings-card-title">Calibration Management</h3>
<p class="settings-card-description">Manage calibration data for accurate measurements</p>
<!-- Current Calibration Status -->
<div class="calibration-status" id="calibrationStatus">
<div class="status-item">
<span class="status-label">Current Calibration:</span>
<span class="status-value" id="currentCalibration">None</span>
</div>
</div>
<!-- Calibration Workflow -->
<div class="calibration-workflow">
<!-- Start New Calibration -->
<div class="workflow-section">
<h4 class="workflow-title">New Calibration</h4>
<div class="workflow-controls">
<button class="btn btn--primary" id="startCalibrationBtn" disabled>
<i data-lucide="play"></i>
Start Calibration
</button>
</div>
</div>
<!-- Calibration Steps -->
<div class="workflow-section" id="calibrationSteps" style="display: none;">
<h4 class="workflow-title">Calibration Steps</h4>
<div class="calibration-progress" id="calibrationProgress">
<div class="progress-info">
<span class="progress-text" id="progressText">0/0</span>
</div>
</div>
<div class="calibration-standards" id="calibrationStandards">
<!-- Standards buttons will be dynamically generated -->
</div>
<div class="calibration-actions">
<input type="text" class="calibration-name-input" id="calibrationNameInput" placeholder="Enter calibration name" disabled>
<button class="btn btn--success" id="saveCalibrationBtn" disabled>
<i data-lucide="save"></i>
Save Calibration
</button>
</div>
</div>
<!-- Existing Calibrations -->
<div class="workflow-section">
<h4 class="workflow-title">Existing Calibrations</h4>
<div class="existing-calibrations" id="existingCalibrations">
<select class="calibration-dropdown" id="calibrationDropdown" disabled>
<option value="">No calibrations available</option>
</select>
<button class="btn btn--primary" id="setCalibrationBtn" disabled>
<i data-lucide="check"></i>
Set Active
</button>
</div>
</div>
</div>
</div>
<!-- Status Summary -->
<div class="settings-card">
<h3 class="settings-card-title">Status Summary</h3>
<div class="status-grid" id="statusSummary">
<div class="status-item">
<span class="status-label">Available Presets:</span>
<span class="status-value" id="presetCount">-</span>
</div>
<div class="status-item">
<span class="status-label">Available Calibrations:</span>
<span class="status-value" id="calibrationCount">-</span>
</div>
<div class="status-item">
<span class="status-label">System Status:</span>
<span class="status-value" id="systemStatus">Checking...</span>
</div>
</div>
</div>
</div>
</div>
</div>
</main>