improved codestyle and added logging

This commit is contained in:
Ayzen
2025-09-26 17:22:03 +03:00
parent 926268733c
commit 451c0654f5
39 changed files with 27401 additions and 1949 deletions

View File

@ -1,9 +0,0 @@
{
"server": {
"host": "0.0.0.0",
"port": 8000
},
"logging": {
"level": "INFO"
}
}

View File

@ -1,88 +1,148 @@
from fastapi import APIRouter, HTTPException from typing import Any
from fastapi import APIRouter, HTTPException, Query
import vna_system.core.singletons as singletons import vna_system.core.singletons as singletons
from vna_system.core.logging.logger import get_component_logger
router = APIRouter(prefix="/api/v1", tags=["acquisition"]) router = APIRouter(prefix="/api/v1", tags=["acquisition"])
logger = get_component_logger(__file__)
@router.get("/acquisition/status") @router.get("/acquisition/status")
async def get_acquisition_status(): async def get_acquisition_status() -> dict[str, Any]:
"""Get current acquisition status.""" """
Return current acquisition status.
Response
--------
{
"running": bool,
"paused": bool,
"continuous_mode": bool,
"sweep_count": int
}
"""
acquisition = singletons.vna_data_acquisition_instance acquisition = singletons.vna_data_acquisition_instance
if acquisition is None:
logger.error("Acquisition singleton is not initialized")
raise HTTPException(status_code=500, detail="Acquisition not initialized")
return { return {
"running": acquisition.is_running, "running": acquisition.is_running,
"paused": acquisition.is_paused, "paused": acquisition.is_paused,
"continuous_mode": acquisition.is_continuous_mode, "continuous_mode": acquisition.is_continuous_mode,
"sweep_count": acquisition._sweep_buffer._sweep_counter if hasattr(acquisition._sweep_buffer, '_sweep_counter') else 0 "sweep_count": acquisition.sweep_buffer.current_sweep_number,
} }
@router.post("/acquisition/start") @router.post("/acquisition/start")
async def start_acquisition(): async def start_acquisition() -> dict[str, Any]:
"""Start data acquisition.""" """
Start data acquisition in continuous mode (resumes if paused).
"""
try: try:
acquisition = singletons.vna_data_acquisition_instance acquisition = singletons.vna_data_acquisition_instance
if acquisition is None:
logger.error("Acquisition singleton is not initialized")
raise HTTPException(status_code=500, detail="Acquisition not initialized")
if not acquisition.is_running: if not acquisition.is_running:
# Start thread if not running
acquisition.start() acquisition.start()
logger.info("Acquisition thread started via API")
# Set to continuous mode (also resumes if paused)
acquisition.set_continuous_mode(True) acquisition.set_continuous_mode(True)
return {"success": True, "message": "Acquisition started"} return {"success": True, "message": "Acquisition started"}
except Exception as e: except HTTPException:
raise HTTPException(status_code=500, detail=str(e)) raise
except Exception as exc: # noqa: BLE001
logger.error("Failed to start acquisition")
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/acquisition/stop") @router.post("/acquisition/stop")
async def stop_acquisition(): async def stop_acquisition() -> dict[str, Any]:
"""Stop/pause data acquisition.""" """
Pause data acquisition (thread remains alive for fast resume).
"""
try: try:
acquisition = singletons.vna_data_acquisition_instance acquisition = singletons.vna_data_acquisition_instance
if acquisition is None:
logger.error("Acquisition singleton is not initialized")
raise HTTPException(status_code=500, detail="Acquisition not initialized")
if not acquisition.is_running: if not acquisition.is_running:
return {"success": True, "message": "Acquisition already stopped"} return {"success": True, "message": "Acquisition already stopped"}
# Just pause instead of full stop - keeps thread alive for restart
acquisition.pause() acquisition.pause()
return {"success": True, "message": "Acquisition stopped"} logger.info("Acquisition paused via API")
except Exception as e: return {"success": True, "message": "Acquisition paused"}
raise HTTPException(status_code=500, detail=str(e)) except HTTPException:
raise
except Exception as exc: # noqa: BLE001
logger.error("Failed to stop acquisition")
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/acquisition/single-sweep") @router.post("/acquisition/single-sweep")
async def trigger_single_sweep(): async def trigger_single_sweep() -> dict[str, Any]:
"""Trigger a single sweep. Automatically starts acquisition if needed.""" """
Trigger a single sweep.
Automatically starts acquisition if needed and switches to single-sweep mode.
"""
try: try:
acquisition = singletons.vna_data_acquisition_instance acquisition = singletons.vna_data_acquisition_instance
if acquisition is None:
logger.error("Acquisition singleton is not initialized")
raise HTTPException(status_code=500, detail="Acquisition not initialized")
if not acquisition.is_running: if not acquisition.is_running:
# Start acquisition if not running
acquisition.start() acquisition.start()
logger.info("Acquisition thread started (single-sweep request)")
acquisition.trigger_single_sweep() acquisition.trigger_single_sweep()
return {"success": True, "message": "Single sweep triggered"} return {"success": True, "message": "Single sweep triggered"}
except Exception as e: except HTTPException:
raise HTTPException(status_code=500, detail=str(e)) raise
except Exception as exc: # noqa: BLE001
logger.error("Failed to trigger single sweep")
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/acquisition/latest-sweep") @router.get("/acquisition/latest-sweep")
async def get_latest_sweep(): async def get_latest_sweep(
"""Get the latest sweep data.""" limit: int = Query(10, ge=1, le=1000, description="Max number of points to include in response"),
) -> dict[str, Any]:
"""
Return the latest sweep metadata and a limited subset of points.
Query Params
------------
limit : int
Number of points to include from the start of the sweep (default 10, max 1000).
"""
try: try:
acquisition = singletons.vna_data_acquisition_instance acquisition = singletons.vna_data_acquisition_instance
latest_sweep = acquisition._sweep_buffer.get_latest_sweep() if acquisition is None:
logger.error("Acquisition singleton is not initialized")
raise HTTPException(status_code=500, detail="Acquisition not initialized")
latest_sweep = acquisition.sweep_buffer.get_latest_sweep()
if not latest_sweep: if not latest_sweep:
return {"sweep": None, "message": "No sweep data available"} return {"sweep": None, "message": "No sweep data available"}
points = latest_sweep.points[:limit]
return { return {
"sweep": { "sweep": {
"sweep_number": latest_sweep.sweep_number, "sweep_number": latest_sweep.sweep_number,
"timestamp": latest_sweep.timestamp, "timestamp": latest_sweep.timestamp,
"total_points": latest_sweep.total_points, "total_points": latest_sweep.total_points,
"points": latest_sweep.points[:10] if len(latest_sweep.points) > 10 else latest_sweep.points # Limit for API response "points": points,
}, },
"message": f"Latest sweep #{latest_sweep.sweep_number} with {latest_sweep.total_points} points" "message": f"Latest sweep #{latest_sweep.sweep_number} with {latest_sweep.total_points} points",
} }
except Exception as e: except HTTPException:
raise HTTPException(status_code=500, detail=str(e)) raise
except Exception as exc: # noqa: BLE001
logger.error("Failed to fetch latest sweep")
raise HTTPException(status_code=500, detail=str(exc))

View File

@ -1,10 +1,13 @@
from typing import Any, List # pydantic response_model uses List
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from typing import List
from pathlib import Path
import vna_system.core.singletons as singletons import vna_system.core.singletons as singletons
from vna_system.core.logging.logger import get_component_logger
from vna_system.core.settings.calibration_manager import CalibrationStandard from vna_system.core.settings.calibration_manager import CalibrationStandard
from vna_system.core.visualization.magnitude_chart import generate_standards_magnitude_plots, generate_combined_standards_plot from vna_system.core.visualization.magnitude_chart import (
generate_standards_magnitude_plots,
)
from vna_system.api.models.settings import ( from vna_system.api.models.settings import (
PresetModel, PresetModel,
CalibrationModel, CalibrationModel,
@ -15,74 +18,81 @@ from vna_system.api.models.settings import (
SaveCalibrationRequest, SaveCalibrationRequest,
SetCalibrationRequest, SetCalibrationRequest,
RemoveStandardRequest, RemoveStandardRequest,
WorkingCalibrationModel WorkingCalibrationModel,
) )
router = APIRouter(prefix="/api/v1/settings", tags=["settings"]) router = APIRouter(prefix="/api/v1/settings", tags=["settings"])
logger = get_component_logger(__file__)
@router.get("/status", response_model=SettingsStatusModel) @router.get("/status", response_model=SettingsStatusModel)
async def get_status(): async def get_status() -> dict[str, Any]:
"""Get current settings status""" """Get current settings status."""
try: try:
status = singletons.settings_manager.get_status_summary() return singletons.settings_manager.get_status_summary()
return status except Exception as exc: # noqa: BLE001
except Exception as e: logger.error("Failed to get settings status")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(exc))
@router.get("/presets", response_model=List[PresetModel]) @router.get("/presets", response_model=List[PresetModel])
async def get_presets(mode: str | None = None): async def get_presets(mode: str | None = None) -> list[PresetModel]:
"""Get all available configuration presets, optionally filtered by mode""" """Get all available configuration presets, optionally filtered by mode."""
try: try:
if mode: if mode:
from vna_system.core.settings.preset_manager import VNAMode from vna_system.core.settings.preset_manager import VNAMode
try: try:
vna_mode = VNAMode(mode.lower()) vna_mode = VNAMode(mode.lower())
presets = singletons.settings_manager.get_presets_by_mode(vna_mode)
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid mode: {mode}") raise HTTPException(status_code=400, detail=f"Invalid mode: {mode}")
presets = singletons.settings_manager.get_presets_by_mode(vna_mode)
else: else:
presets = singletons.settings_manager.get_available_presets() presets = singletons.settings_manager.get_available_presets()
return [ return [
PresetModel( PresetModel(
filename=preset.filename, filename=p.filename,
mode=preset.mode.value, mode=p.mode.value,
start_freq=preset.start_freq, start_freq=p.start_freq,
stop_freq=preset.stop_freq, stop_freq=p.stop_freq,
points=preset.points, points=p.points,
bandwidth=preset.bandwidth bandwidth=p.bandwidth,
) )
for preset in presets for p in presets
] ]
except Exception as e: except HTTPException:
raise HTTPException(status_code=500, detail=str(e)) raise
except Exception as exc: # noqa: BLE001
logger.error("Failed to list presets")
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/preset/set") @router.post("/preset/set")
async def set_preset(request: SetPresetRequest): async def set_preset(request: SetPresetRequest) -> dict[str, Any]:
"""Set current configuration preset""" """Set current configuration preset."""
try: try:
# Find preset by filename
presets = singletons.settings_manager.get_available_presets() presets = singletons.settings_manager.get_available_presets()
preset = next((p for p in presets if p.filename == request.filename), None) preset = next((p for p in presets if p.filename == request.filename), None)
if preset is None:
if not preset:
raise HTTPException(status_code=404, detail=f"Preset not found: {request.filename}") raise HTTPException(status_code=404, detail=f"Preset not found: {request.filename}")
# Clear current calibration when changing preset # Changing preset invalidates active calibration selection.
singletons.settings_manager.calibration_manager.clear_current_calibration() singletons.settings_manager.calibration_manager.clear_current_calibration()
singletons.settings_manager.set_current_preset(preset) singletons.settings_manager.set_current_preset(preset)
logger.info("Preset selected via API", filename=preset.filename, mode=preset.mode.value)
return {"success": True, "message": f"Preset set to {request.filename}"} return {"success": True, "message": f"Preset set to {request.filename}"}
except Exception as e: except HTTPException:
raise HTTPException(status_code=500, detail=str(e)) raise
except Exception as exc: # noqa: BLE001
logger.error("Failed to set preset")
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/preset/current", response_model=PresetModel | None) @router.get("/preset/current", response_model=PresetModel | None)
async def get_current_preset(): async def get_current_preset():
"""Get currently selected configuration preset""" """Get currently selected configuration preset."""
try: try:
preset = singletons.settings_manager.get_current_preset() preset = singletons.settings_manager.get_current_preset()
if not preset: if not preset:
@ -94,171 +104,173 @@ async def get_current_preset():
start_freq=preset.start_freq, start_freq=preset.start_freq,
stop_freq=preset.stop_freq, stop_freq=preset.stop_freq,
points=preset.points, points=preset.points,
bandwidth=preset.bandwidth bandwidth=preset.bandwidth,
) )
except Exception as e: except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=str(e)) logger.error("Failed to get current preset")
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/calibrations", response_model=List[CalibrationModel]) @router.get("/calibrations", response_model=List[CalibrationModel])
async def get_calibrations(preset_filename: str | None = None): async def get_calibrations(preset_filename: str | None = None) -> list[CalibrationModel]:
"""Get available calibrations for current or specified preset""" """Get available calibrations for current or specified preset."""
try: try:
preset = None preset = None
if preset_filename: if preset_filename:
presets = singletons.settings_manager.get_available_presets() presets = singletons.settings_manager.get_available_presets()
preset = next((p for p in presets if p.filename == preset_filename), None) preset = next((p for p in presets if p.filename == preset_filename), None)
if not preset: if preset is None:
raise HTTPException(status_code=404, detail=f"Preset not found: {preset_filename}") raise HTTPException(status_code=404, detail=f"Preset not found: {preset_filename}")
calibrations = singletons.settings_manager.get_available_calibrations(preset) calibrations = singletons.settings_manager.get_available_calibrations(preset)
details: list[CalibrationModel] = []
# Get detailed info for each calibration
calibration_details = []
current_preset = preset or singletons.settings_manager.get_current_preset() current_preset = preset or singletons.settings_manager.get_current_preset()
if current_preset: if current_preset:
for calib_name in calibrations: for name in calibrations:
info = singletons.settings_manager.get_calibration_info(calib_name, current_preset) info = singletons.settings_manager.get_calibration_info(name, current_preset)
standards = info.get("standards", {})
# Convert standards format if needed # Normalize standards into {standard: bool}
standards = info.get('standards', {})
if isinstance(standards, list): if isinstance(standards, list):
# If standards is a list (from complete calibration), convert to dict required = singletons.settings_manager.get_required_standards(current_preset.mode)
required_standards = singletons.settings_manager.get_required_standards(current_preset.mode) standards = {std.value: (std.value in standards) for std in required}
standards = {std.value: std.value in standards for std in required_standards}
calibration_details.append(CalibrationModel( details.append(
name=calib_name, CalibrationModel(
is_complete=info.get('is_complete', False), name=name,
standards=standards is_complete=bool(info.get("is_complete", False)),
)) standards=standards,
)
)
return calibration_details return details
except Exception as e: except HTTPException:
raise HTTPException(status_code=500, detail=str(e)) raise
except Exception as exc: # noqa: BLE001
logger.error("Failed to list calibrations")
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/calibration/start") @router.post("/calibration/start")
async def start_calibration(request: StartCalibrationRequest): async def start_calibration(request: StartCalibrationRequest) -> dict[str, Any]:
"""Start new calibration for current or specified preset""" """Start new calibration for current or specified preset."""
try: try:
preset = None preset = None
if request.preset_filename: if request.preset_filename:
presets = singletons.settings_manager.get_available_presets() presets = singletons.settings_manager.get_available_presets()
preset = next((p for p in presets if p.filename == request.preset_filename), None) preset = next((p for p in presets if p.filename == request.preset_filename), None)
if not preset: if preset is None:
raise HTTPException(status_code=404, detail=f"Preset not found: {request.preset_filename}") raise HTTPException(status_code=404, detail=f"Preset not found: {request.preset_filename}")
calibration_set = singletons.settings_manager.start_new_calibration(preset) calib = singletons.settings_manager.start_new_calibration(preset)
required_standards = singletons.settings_manager.get_required_standards(calibration_set.preset.mode) required = singletons.settings_manager.get_required_standards(calib.preset.mode)
return { return {
"success": True, "success": True,
"message": "Calibration started", "message": "Calibration started",
"preset": calibration_set.preset.filename, "preset": calib.preset.filename,
"required_standards": [s.value for s in required_standards] "required_standards": [s.value for s in required],
} }
except Exception as e: except HTTPException:
raise HTTPException(status_code=500, detail=str(e)) raise
except Exception as exc: # noqa: BLE001
logger.error("Failed to start calibration")
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/calibration/add-standard") @router.post("/calibration/add-standard")
async def add_calibration_standard(request: CalibrateStandardRequest): async def add_calibration_standard(request: CalibrateStandardRequest) -> dict[str, Any]:
"""Add calibration standard from latest sweep""" """Add calibration standard from the latest sweep."""
try: try:
# Validate standard
try: try:
standard = CalibrationStandard(request.standard) standard = CalibrationStandard(request.standard)
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid calibration standard: {request.standard}") raise HTTPException(status_code=400, detail=f"Invalid calibration standard: {request.standard}")
# Capture from data acquisition sweep_no = singletons.settings_manager.capture_calibration_standard_from_acquisition(
sweep_number = singletons.settings_manager.capture_calibration_standard_from_acquisition(
standard, singletons.vna_data_acquisition_instance standard, singletons.vna_data_acquisition_instance
) )
# Get current working calibration status working = singletons.settings_manager.get_current_working_calibration()
working_calib = singletons.settings_manager.get_current_working_calibration() progress = working.get_progress() if working else (0, 0)
progress = working_calib.get_progress() if working_calib else (0, 0)
return { return {
"success": True, "success": True,
"message": f"Added {standard.value} standard from sweep {sweep_number}", "message": f"Added {standard.value} standard from sweep {sweep_no}",
"sweep_number": sweep_number, "sweep_number": sweep_no,
"progress": f"{progress[0]}/{progress[1]}", "progress": f"{progress[0]}/{progress[1]}",
"is_complete": working_calib.is_complete() if working_calib else False "is_complete": working.is_complete() if working else False,
} }
except Exception as e: except HTTPException:
raise HTTPException(status_code=500, detail=str(e)) raise
except Exception as exc: # noqa: BLE001
logger.error("Failed to add calibration standard")
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/calibration/save") @router.post("/calibration/save")
async def save_calibration(request: SaveCalibrationRequest): async def save_calibration(request: SaveCalibrationRequest) -> dict[str, Any]:
"""Save current working calibration set""" """Save current working calibration set."""
try: try:
calibration_set = singletons.settings_manager.save_calibration_set(request.name) saved = singletons.settings_manager.save_calibration_set(request.name)
return { return {
"success": True, "success": True,
"message": f"Calibration '{request.name}' saved successfully", "message": f"Calibration '{request.name}' saved successfully",
"preset": calibration_set.preset.filename, "preset": saved.preset.filename,
"standards": list(calibration_set.standards.keys()) "standards": [s.value for s in saved.standards.keys()],
} }
except Exception as e: except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=str(e)) logger.error("Failed to save calibration")
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/calibration/set") @router.post("/calibration/set")
async def set_calibration(request: SetCalibrationRequest): async def set_calibration(request: SetCalibrationRequest) -> dict[str, Any]:
"""Set current active calibration""" """Set current active calibration."""
try: try:
preset = None preset = None
if request.preset_filename: if request.preset_filename:
presets = singletons.settings_manager.get_available_presets() presets = singletons.settings_manager.get_available_presets()
preset = next((p for p in presets if p.filename == request.preset_filename), None) preset = next((p for p in presets if p.filename == request.preset_filename), None)
if not preset: if preset is None:
raise HTTPException(status_code=404, detail=f"Preset not found: {request.preset_filename}") raise HTTPException(status_code=404, detail=f"Preset not found: {request.preset_filename}")
singletons.settings_manager.set_current_calibration(request.name, preset) singletons.settings_manager.set_current_calibration(request.name, preset)
return {"success": True, "message": f"Calibration set to '{request.name}'"}
return { except HTTPException:
"success": True, raise
"message": f"Calibration set to '{request.name}'" except Exception as exc: # noqa: BLE001
} logger.error("Failed to set calibration")
except Exception as e: raise HTTPException(status_code=500, detail=str(exc))
raise HTTPException(status_code=500, detail=str(e))
@router.get("/working-calibration", response_model=WorkingCalibrationModel) @router.get("/working-calibration", response_model=WorkingCalibrationModel)
async def get_working_calibration(): async def get_working_calibration() -> WorkingCalibrationModel:
"""Get current working calibration status""" """Get current working calibration status."""
try: try:
working_calib = singletons.settings_manager.get_current_working_calibration() working = singletons.settings_manager.get_current_working_calibration()
if not working:
if not working_calib:
return WorkingCalibrationModel(active=False) return WorkingCalibrationModel(active=False)
completed, total = working_calib.get_progress() completed, total = working.get_progress()
missing_standards = working_calib.get_missing_standards()
return WorkingCalibrationModel( return WorkingCalibrationModel(
active=True, active=True,
preset=working_calib.preset.filename, preset=working.preset.filename,
progress=f"{completed}/{total}", progress=f"{completed}/{total}",
is_complete=working_calib.is_complete(), is_complete=working.is_complete(),
completed_standards=[s.value for s in working_calib.standards.keys()], completed_standards=[s.value for s in working.standards.keys()],
missing_standards=[s.value for s in missing_standards] missing_standards=[s.value for s in working.get_missing_standards()],
) )
except Exception as e: except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=str(e)) logger.error("Failed to get working calibration")
raise HTTPException(status_code=500, detail=str(exc))
@router.delete("/calibration/remove-standard") @router.delete("/calibration/remove-standard")
async def remove_calibration_standard(request: RemoveStandardRequest): async def remove_calibration_standard(request: RemoveStandardRequest) -> dict[str, Any]:
"""Remove calibration standard from current working set""" """Remove calibration standard from current working set."""
try: try:
# Validate standard
try: try:
standard = CalibrationStandard(request.standard) standard = CalibrationStandard(request.standard)
except ValueError: except ValueError:
@ -266,153 +278,140 @@ async def remove_calibration_standard(request: RemoveStandardRequest):
singletons.settings_manager.remove_calibration_standard(standard) singletons.settings_manager.remove_calibration_standard(standard)
# Get current working calibration status working = singletons.settings_manager.get_current_working_calibration()
working_calib = singletons.settings_manager.get_current_working_calibration() progress = working.get_progress() if working else (0, 0)
progress = working_calib.get_progress() if working_calib else (0, 0)
return { return {
"success": True, "success": True,
"message": f"Removed {standard.value} standard", "message": f"Removed {standard.value} standard",
"progress": f"{progress[0]}/{progress[1]}", "progress": f"{progress[0]}/{progress[1]}",
"is_complete": working_calib.is_complete() if working_calib else False "is_complete": working.is_complete() if working else False,
} }
except Exception as e: except HTTPException:
raise HTTPException(status_code=500, detail=str(e)) raise
except Exception as exc: # noqa: BLE001
logger.error("Failed to remove calibration standard")
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/calibration/current") @router.get("/calibration/current")
async def get_current_calibration(): async def get_current_calibration() -> dict[str, Any]:
"""Get currently selected calibration details""" """Get currently selected calibration details."""
try: try:
current_calib = singletons.settings_manager.get_current_calibration() current = singletons.settings_manager.get_current_calibration()
if not current:
if not current_calib:
return {"active": False} return {"active": False}
return { return {
"active": True, "active": True,
"preset": { "preset": {"filename": current.preset.filename, "mode": current.preset.mode.value},
"filename": current_calib.preset.filename, "calibration_name": current.name,
"mode": current_calib.preset.mode.value "standards": [s.value for s in current.standards.keys()],
}, "is_complete": current.is_complete(),
"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: except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=str(e)) logger.error("Failed to get current calibration")
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/calibration/{calibration_name}/standards-plots") @router.get("/calibration/{calibration_name}/standards-plots")
async def get_calibration_standards_plots(calibration_name: str, preset_filename: str = None): async def get_calibration_standards_plots(
"""Get magnitude plots for all standards in a calibration set""" calibration_name: str,
preset_filename: str | None = None,
) -> dict[str, Any]:
"""Get magnitude plots for all standards in a calibration set."""
try: try:
# Get preset # Resolve preset (explicit or current)
preset = None preset = None
if preset_filename: if preset_filename:
presets = singletons.settings_manager.get_available_presets() presets = singletons.settings_manager.get_available_presets()
preset = next((p for p in presets if p.filename == preset_filename), None) preset = next((p for p in presets if p.filename == preset_filename), None)
if not preset: if preset is None:
raise HTTPException(status_code=404, detail=f"Preset not found: {preset_filename}") raise HTTPException(status_code=404, detail=f"Preset not found: {preset_filename}")
else: else:
preset = singletons.settings_manager.get_current_preset() preset = singletons.settings_manager.get_current_preset()
if not preset: if preset is None:
raise HTTPException(status_code=400, detail="No current preset selected") raise HTTPException(status_code=400, detail="No current preset selected")
# Get calibration directory # Resolve calibration directory (uses manager's internal layout)
calibration_manager = singletons.settings_manager.calibration_manager calibration_manager = singletons.settings_manager.calibration_manager
calibration_dir = calibration_manager._get_preset_calibration_dir(preset) / calibration_name calibration_dir = calibration_manager._get_preset_calibration_dir(preset) / calibration_name # noqa: SLF001
if not calibration_dir.exists(): if not calibration_dir.exists():
raise HTTPException(status_code=404, detail=f"Calibration not found: {calibration_name}") raise HTTPException(status_code=404, detail=f"Calibration not found: {calibration_name}")
# Generate plots for each standard
individual_plots = generate_standards_magnitude_plots(calibration_dir, preset) individual_plots = generate_standards_magnitude_plots(calibration_dir, preset)
return { return {
"calibration_name": calibration_name, "calibration_name": calibration_name,
"preset": { "preset": {"filename": preset.filename, "mode": preset.mode.value},
"filename": preset.filename, "individual_plots": individual_plots,
"mode": preset.mode.value
},
"individual_plots": individual_plots
} }
except Exception as e: except HTTPException:
raise HTTPException(status_code=500, detail=str(e)) raise
except Exception as exc: # noqa: BLE001
logger.error("Failed to build calibration standards plots")
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/working-calibration/standards-plots") @router.get("/working-calibration/standards-plots")
async def get_working_calibration_standards_plots(): async def get_working_calibration_standards_plots() -> dict[str, Any]:
"""Get magnitude plots for standards in current working calibration""" """Get magnitude plots for standards in the current working calibration."""
try: try:
working_calib = singletons.settings_manager.get_current_working_calibration() working = singletons.settings_manager.get_current_working_calibration()
if not working:
if not working_calib:
raise HTTPException(status_code=404, detail="No working calibration active") raise HTTPException(status_code=404, detail="No working calibration active")
if not working.standards:
# Check if there are any standards captured
if not working_calib.standards:
raise HTTPException(status_code=404, detail="No standards captured in working calibration") raise HTTPException(status_code=404, detail="No standards captured in working calibration")
# Generate plots directly from in-memory sweep data
from vna_system.core.visualization.magnitude_chart import generate_magnitude_plot_from_sweep_data from vna_system.core.visualization.magnitude_chart import generate_magnitude_plot_from_sweep_data
individual_plots = {} individual: dict[str, Any] = {}
standard_colors = { standard_colors = {
'open': '#2ca02c', # Green "open": "#2ca02c",
'short': '#d62728', # Red "short": "#d62728",
'load': '#ff7f0e', # Orange "load": "#ff7f0e",
'through': '#1f77b4' # Blue "through": "#1f77b4",
} }
for standard, sweep_data in working_calib.standards.items(): for standard, sweep in working.standards.items():
try: try:
# Generate plot for this standard fig = generate_magnitude_plot_from_sweep_data(sweep, working.preset)
plot_config = generate_magnitude_plot_from_sweep_data(sweep_data, working_calib.preset) if "error" not in fig and fig.get("data"):
fig["data"][0]["line"]["color"] = standard_colors.get(standard.value, "#1f77b4")
fig["data"][0]["name"] = f"{standard.value.upper()} Standard"
fig["layout"]["title"] = f"{standard.value.upper()} Standard Magnitude (Working)"
if 'error' not in plot_config: fig["raw_sweep_data"] = {
# Customize color and title for this standard "sweep_number": sweep.sweep_number,
if plot_config.get('data'): "timestamp": sweep.timestamp,
plot_config['data'][0]['line']['color'] = standard_colors.get(standard.value, '#1f77b4') "total_points": sweep.total_points,
plot_config['data'][0]['name'] = f'{standard.value.upper()} Standard' "points": sweep.points,
plot_config['layout']['title'] = f'{standard.value.upper()} Standard Magnitude (Working)' "file_path": None,
# Include raw sweep data for download
plot_config['raw_sweep_data'] = {
'sweep_number': sweep_data.sweep_number,
'timestamp': sweep_data.timestamp,
'total_points': sweep_data.total_points,
'points': sweep_data.points, # Raw complex data points
'file_path': None # No file path for working calibration
} }
# Add frequency information fig["frequency_info"] = {
plot_config['frequency_info'] = { "start_freq": working.preset.start_freq,
'start_freq': working_calib.preset.start_freq, "stop_freq": working.preset.stop_freq,
'stop_freq': working_calib.preset.stop_freq, "points": working.preset.points,
'points': working_calib.preset.points, "bandwidth": working.preset.bandwidth,
'bandwidth': working_calib.preset.bandwidth
} }
individual[standard.value] = fig
except Exception as exc: # noqa: BLE001
individual[standard.value] = {"error": f"Failed to generate plot for {standard.value}: {exc}"}
individual_plots[standard.value] = plot_config if not individual:
else:
individual_plots[standard.value] = plot_config
except Exception as e:
individual_plots[standard.value] = {'error': f'Failed to generate plot for {standard.value}: {str(e)}'}
if not individual_plots:
raise HTTPException(status_code=404, detail="No valid plots generated for working calibration") raise HTTPException(status_code=404, detail="No valid plots generated for working calibration")
return { return {
"calibration_name": "Working Calibration", "calibration_name": "Working Calibration",
"preset": { "preset": {"filename": working.preset.filename, "mode": working.preset.mode.value},
"filename": working_calib.preset.filename, "individual_plots": individual,
"mode": working_calib.preset.mode.value
},
"individual_plots": individual_plots,
"is_working": True, "is_working": True,
"is_complete": working_calib.is_complete() "is_complete": working.is_complete(),
} }
except Exception as e: except HTTPException:
raise HTTPException(status_code=500, detail=str(e)) raise
except Exception as exc: # noqa: BLE001
logger.error("Failed to build working calibration standards plots")
raise HTTPException(status_code=500, detail=str(exc))

View File

@ -1,143 +0,0 @@
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.api.endpoints import health, settings, web_ui, acquisition
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()
# Initialize processor system
logger.info("Starting processor system...")
singletons.processor_manager.start_processing()
logger.info(f"Processor system started with processors: {singletons.processor_manager.list_processors()}")
logger.info("VNA API Server started successfully")
yield
except Exception as e:
logger.error(f"Error during startup: {e}")
raise
# Shutdown
logger.info("Shutting down VNA API Server...")
if singletons.processor_manager:
singletons.processor_manager.stop_processing()
logger.info("Processor system stopped")
if singletons.vna_data_acquisition_instance and singletons.vna_data_acquisition_instance._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(acquisition.router)
app.include_router(settings.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()

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": "еуыеуые",
"standards": [
"open",
"load",
"short"
],
"created_timestamp": "2025-09-26T17:19:50.019248",
"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": "еуыеуые",
"standard": "load",
"sweep_number": 12,
"sweep_timestamp": 1758896376.33808,
"created_timestamp": "2025-09-26T17:19:50.017201",
"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": "еуыеуые",
"standard": "open",
"sweep_number": 10,
"sweep_timestamp": 1758896372.20023,
"created_timestamp": "2025-09-26T17:19:50.015286",
"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": "еуыеуые",
"standard": "short",
"sweep_number": 13,
"sweep_timestamp": 1758896378.4093437,
"created_timestamp": "2025-09-26T17:19:50.019159",
"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": "яыф",
"standards": [
"open",
"load",
"short"
],
"created_timestamp": "2025-09-26T17:20:00.022650",
"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": "яыф",
"standard": "load",
"sweep_number": 12,
"sweep_timestamp": 1758896376.33808,
"created_timestamp": "2025-09-26T17:20:00.020322",
"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": "яыф",
"standard": "open",
"sweep_number": 17,
"sweep_timestamp": 1758896395.4880857,
"created_timestamp": "2025-09-26T17:20:00.016886",
"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": "яыф",
"standard": "short",
"sweep_number": 13,
"sweep_timestamp": 1758896378.4093437,
"created_timestamp": "2025-09-26T17:20:00.022500",
"total_points": 1000
}

View File

@ -1,36 +1,36 @@
from __future__ import annotations
import io import io
import logging
import os import os
import struct import struct
import threading import threading
import time import time
from typing import BinaryIO, List, Tuple from typing import BinaryIO
import serial import serial
from vna_system.core import config as cfg from vna_system.core import config as cfg
from vna_system.core.acquisition.port_manager import VNAPortLocator
from vna_system.core.acquisition.sweep_buffer import SweepBuffer from vna_system.core.acquisition.sweep_buffer import SweepBuffer
from vna_system.core.logging.logger import get_component_logger
logger = get_component_logger(__file__)
logger = logging.getLogger(__name__)
class VNADataAcquisition: class VNADataAcquisition:
"""Main data acquisition class with asynchronous sweep collection.""" """Main data acquisition class with asynchronous sweep collection."""
def __init__(self) -> None: def __init__(self) -> None:
# Configuration
self.bin_log_path: str = cfg.BIN_INPUT_FILE_PATH self.bin_log_path: str = cfg.BIN_INPUT_FILE_PATH
self.baud: int = cfg.DEFAULT_BAUD_RATE self.baud: int = cfg.DEFAULT_BAUD_RATE
# Dependencies
self.vna_port_locator = VNAPortLocator()
self._sweep_buffer = SweepBuffer() self._sweep_buffer = SweepBuffer()
# Control flags # Control flags
self._running: bool = False self._running: bool = False
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self._stop_event: threading.Event = threading.Event() self._stop_event = threading.Event()
self._paused: bool = False self._paused: bool = False
# Acquisition modes # Acquisition modes
@ -39,29 +39,30 @@ class VNADataAcquisition:
# Sweep collection state # Sweep collection state
self._collecting: bool = False self._collecting: bool = False
self._collected_rx_payloads: List[bytes] = [] self._collected_rx_payloads: list[bytes] = []
self._meas_cmds_in_sweep: int = 0 self._meas_cmds_in_sweep: int = 0
logger.debug("VNADataAcquisition initialized", baud=self.baud, bin_log_path=self.bin_log_path)
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
# Lifecycle # Lifecycle
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
def start(self) -> None: def start(self) -> None:
"""Start the data acquisition background thread.""" """Start the data acquisition background thread."""
if self._running: if self._running:
logger.debug("Acquisition already running; start() call ignored.") logger.debug("start() ignored; acquisition already running")
return return
self._running = True self._running = True
self._stop_event.clear() self._stop_event.clear()
self._thread = threading.Thread(target=self._acquisition_loop, daemon=True) self._thread = threading.Thread(target=self._acquisition_loop, daemon=True, name="VNA-Acq")
self._thread.start() self._thread.start()
logger.info("Acquisition thread started.") logger.info("Acquisition thread started")
def stop(self) -> None: def stop(self) -> None:
"""Stop the data acquisition background thread.""" """Stop the data acquisition background thread."""
if not self._running: if not self._running:
logger.debug("Acquisition not running; stop() call ignored.") logger.debug("stop() ignored; acquisition not running")
return return
self._running = False self._running = False
@ -69,8 +70,7 @@ class VNADataAcquisition:
if self._thread and self._thread.is_alive(): if self._thread and self._thread.is_alive():
self._thread.join(timeout=5.0) self._thread.join(timeout=5.0)
logger.info("Acquisition thread joined.") logger.info("Acquisition thread joined")
@property @property
def is_running(self) -> bool: def is_running(self) -> bool:
@ -95,7 +95,7 @@ class VNADataAcquisition:
def pause(self) -> None: def pause(self) -> None:
"""Pause the data acquisition.""" """Pause the data acquisition."""
if not self._running: if not self._running:
logger.warning("Cannot pause: acquisition not running") logger.warning("Cannot pause; acquisition not running")
return return
self._paused = True self._paused = True
@ -105,24 +105,21 @@ class VNADataAcquisition:
"""Set continuous or single sweep mode. Also resumes if paused.""" """Set continuous or single sweep mode. Also resumes if paused."""
self._continuous_mode = continuous self._continuous_mode = continuous
# Resume acquisition if setting to continuous mode and currently paused
if continuous and self._paused: if continuous and self._paused:
self._paused = False self._paused = False
logger.info("Data acquisition resumed (continuous mode)") logger.info("Data acquisition resumed (continuous mode=True)")
mode_str = "continuous" if continuous else "single sweep" logger.info("Acquisition mode updated", continuous=continuous)
logger.info(f"Acquisition mode set to: {mode_str}")
def trigger_single_sweep(self) -> None: def trigger_single_sweep(self) -> None:
"""Trigger a single sweep. Automatically switches to single sweep mode if needed.""" """Trigger a single sweep. Automatically switches to single sweep mode if needed."""
if not self._running: if not self._running:
logger.warning("Cannot trigger single sweep: acquisition not running") logger.warning("Cannot trigger single sweep; acquisition not running")
return return
# Switch to single sweep mode if currently in continuous mode
if self._continuous_mode: if self._continuous_mode:
self.set_continuous_mode(False) self.set_continuous_mode(False)
logger.info("Switched from continuous to single sweep mode") logger.info("Switched from continuous to single-sweep mode")
self._single_sweep_requested = True self._single_sweep_requested = True
if self._paused: if self._paused:
@ -131,155 +128,158 @@ class VNADataAcquisition:
logger.info("Single sweep triggered") logger.info("Single sweep triggered")
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
# Serial management # Serial helpers
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
def _drain_serial_input(self, ser: serial.Serial) -> None: def _drain_serial_input(self, ser: serial.Serial) -> None:
"""Drain any pending bytes from the serial input buffer.""" """Drain any pending bytes from the serial input buffer."""
if not ser: if not ser:
return return
drained = 0 drained = 0
while True: while True:
bytes_waiting = getattr(ser, "in_waiting", 0) waiting = getattr(ser, "in_waiting", 0)
if bytes_waiting <= 0: if waiting <= 0:
break break
drained += len(ser.read(bytes_waiting)) drained += len(ser.read(waiting))
time.sleep(cfg.SERIAL_DRAIN_CHECK_DELAY) time.sleep(cfg.SERIAL_DRAIN_CHECK_DELAY)
if drained: if drained:
logger.warning("Drained %d pending byte(s) from serial input.", drained) logger.warning("Drained pending bytes from serial input", bytes=drained)
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
# Acquisition loop # Acquisition loop
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
def _acquisition_loop(self) -> None: def _acquisition_loop(self) -> None:
"""Main acquisition loop executed by the background thread.""" """Main acquisition loop executed by the background thread."""
while self._running and not self._stop_event.is_set(): while self._running and not self._stop_event.is_set():
try: try:
# Check if paused # Honor pause
if self._paused: if self._paused:
time.sleep(0.1) time.sleep(0.1)
continue continue
# Auto-detect port # Auto-detect and validate port
self.port: str = cfg.get_vna_port() port = self.vna_port_locator.find_vna_port()
logger.info(f"Using auto-detected port: {self.port}") if port is None:
logger.warning("VNA port not found; retrying shortly")
time.sleep(0.5)
continue
with serial.Serial(self.port, self.baud) as ser: logger.debug("Using port", device=port, baud=self.baud)
# Open serial + process one sweep from the binary log
with serial.Serial(port, self.baud) as ser:
self._drain_serial_input(ser) self._drain_serial_input(ser)
# Open the log file each iteration to read the next sweep from start
with open(self.bin_log_path, "rb") as raw: with open(self.bin_log_path, "rb") as raw:
buffered = io.BufferedReader(raw, buffer_size=cfg.SERIAL_BUFFER_SIZE) buffered = io.BufferedReader(raw, buffer_size=cfg.SERIAL_BUFFER_SIZE)
self._process_sweep_data(buffered, ser) self._process_sweep_data(buffered, ser)
# Handle single sweep mode # Handle single-sweep mode transitions
if not self._continuous_mode: if not self._continuous_mode:
if self._single_sweep_requested: if self._single_sweep_requested:
self._single_sweep_requested = False self._single_sweep_requested = False
logger.info("Single sweep completed, pausing acquisition") logger.info("Single sweep completed; pausing acquisition")
self.pause() self.pause()
else: else:
# In single sweep mode but no sweep requested, pause
self.pause() self.pause()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.error("Acquisition error: %s", exc) logger.error("Acquisition loop error", error=repr(exc))
time.sleep(1.0) time.sleep(1.0)
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
# Log processing # Log processing
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
def _process_sweep_data(self, f: BinaryIO, ser: serial.Serial) -> None: def _process_sweep_data(self, f: BinaryIO, ser: serial.Serial) -> None:
"""Process the binary log file and collect sweep data one sweep at a time.""" """Process the binary log file and collect sweep data for a single sweep."""
try: try:
# Start from beginning of file for each sweep
f.seek(0)
# Validate header # Validate header
header = self._read_exact(f, len(cfg.MAGIC)) header = self._read_exact(f, len(cfg.MAGIC))
if header != cfg.MAGIC: if header != cfg.MAGIC:
raise ValueError("Invalid log format: MAGIC header mismatch.") raise ValueError("Invalid log format: MAGIC header mismatch")
self._reset_sweep_state() self._reset_sweep_state()
# Process one complete sweep # Read until exactly one sweep is completed
sweep_completed = False sweep_completed = False
while not sweep_completed and self._running and not self._stop_event.is_set(): while not sweep_completed and self._running and not self._stop_event.is_set():
# Read record header dir_b = f.read(1)
dir_byte = f.read(1) if not dir_b:
if not dir_byte: # EOF reached; wait for more data to arrive on disk
# EOF reached without completing sweep - wait and retry logger.debug("EOF reached; waiting for more data")
logger.debug("EOF reached, waiting for more data...")
time.sleep(0.1) time.sleep(0.1)
break break
direction = dir_byte[0] direction = dir_b[0]
(length,) = struct.unpack(">I", self._read_exact(f, 4)) (length,) = struct.unpack(">I", self._read_exact(f, 4))
if direction == cfg.DIR_TO_DEV: if direction == cfg.DIR_TO_DEV:
# TX path: stream to device and inspect for sweep start # TX path: forward to device and inspect for sweep start
first = self._serial_write_from_file(f, length, ser) first = self._serial_write_from_file(f, length, ser)
if not self._collecting and self._is_sweep_start_command(length, first): if not self._collecting and self._is_sweep_start_command(length, first):
self._collecting = True self._collecting = True
self._collected_rx_payloads = [] self._collected_rx_payloads.clear()
self._meas_cmds_in_sweep = 0 self._meas_cmds_in_sweep = 0
logger.info("Starting sweep data collection from device") logger.info("Sweep collection started")
elif direction == cfg.DIR_FROM_DEV: elif direction == cfg.DIR_FROM_DEV:
# RX path: read exact number of bytes from device # RX path: capture bytes from device; keep file pointer in sync
rx_bytes = self._serial_read_exact(length, ser, capture=self._collecting) rx_bytes = self._serial_read_exact(length, ser, capture=self._collecting)
self._skip_bytes(f, length) # Keep log file pointer in sync self._skip_bytes(f, length)
if self._collecting: if self._collecting:
self._collected_rx_payloads.append(rx_bytes) self._collected_rx_payloads.append(rx_bytes)
self._meas_cmds_in_sweep += 1 self._meas_cmds_in_sweep += 1
# Check for sweep completion
if self._meas_cmds_in_sweep >= cfg.MEAS_CMDS_PER_SWEEP: if self._meas_cmds_in_sweep >= cfg.MEAS_CMDS_PER_SWEEP:
self._finalize_sweep() self._finalize_sweep()
sweep_completed = True sweep_completed = True
else:
# Unknown record type: skip bytes to keep in sync
logger.warning("Unknown record direction; skipping", direction=direction, length=length)
self._skip_bytes(f, length)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.error("Processing error: %s", exc) logger.error("Processing error", error=repr(exc))
time.sleep(1.0) time.sleep(1.0)
def _finalize_sweep(self) -> None: def _finalize_sweep(self) -> None:
"""Parse collected payloads into points and push to the buffer.""" """Parse collected payloads into points and push to the buffer."""
all_points: List[Tuple[float, float]] = [] all_points: list[tuple[float, float]] = []
for payload in self._collected_rx_payloads: for payload in self._collected_rx_payloads:
all_points.extend(self._parse_measurement_data(payload)) if payload:
all_points.extend(self._parse_measurement_data(payload))
if all_points: if all_points:
sweep_number = self._sweep_buffer.add_sweep(all_points) sweep_number = self._sweep_buffer.add_sweep(all_points)
logger.info(f"Collected sweep #{sweep_number} with {len(all_points)} data points") logger.info("Sweep collected", sweep_number=sweep_number, points=len(all_points))
if len(all_points) != cfg.EXPECTED_POINTS_PER_SWEEP: if len(all_points) != cfg.EXPECTED_POINTS_PER_SWEEP:
logger.warning( logger.warning(
"Expected %d points, got %d.", "Unexpected number of points",
cfg.EXPECTED_POINTS_PER_SWEEP, expected=cfg.EXPECTED_POINTS_PER_SWEEP,
len(all_points), actual=len(all_points),
) )
else:
logger.warning("No points parsed for sweep")
self._reset_sweep_state() self._reset_sweep_state()
def _reset_sweep_state(self) -> None: def _reset_sweep_state(self) -> None:
"""Reset internal state for the next sweep collection.""" """Reset internal state for the next sweep collection."""
self._collecting = False self._collecting = False
self._collected_rx_payloads = [] self._collected_rx_payloads.clear()
self._meas_cmds_in_sweep = 0 self._meas_cmds_in_sweep = 0
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
# I/O helpers # I/O helpers
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
def _read_exact(self, f: BinaryIO, n: int) -> bytes: def _read_exact(self, f: BinaryIO, n: int) -> bytes:
"""Read exactly *n* bytes from a file-like object or raise EOFError.""" """Read exactly *n* bytes from a file-like object or raise EOFError."""
buf = bytearray() buf = bytearray()
while len(buf) < n: while len(buf) < n:
chunk = f.read(n - len(buf)) chunk = f.read(n - len(buf))
if not chunk: if not chunk:
raise EOFError(f"Unexpected EOF while reading {n} bytes.") raise EOFError(f"Unexpected EOF while reading {n} bytes")
buf += chunk buf += chunk
return bytes(buf) return bytes(buf)
@ -290,14 +290,13 @@ class VNADataAcquisition:
f.seek(n, os.SEEK_CUR) f.seek(n, os.SEEK_CUR)
return return
except (OSError, io.UnsupportedOperation): except (OSError, io.UnsupportedOperation):
# Fall back to manual skipping below.
pass pass
remaining = n remaining = n
while remaining > 0: while remaining > 0:
chunk = f.read(min(cfg.FILE_CHUNK_SIZE, remaining)) chunk = f.read(min(cfg.FILE_CHUNK_SIZE, remaining))
if not chunk: if not chunk:
raise EOFError(f"Unexpected EOF while skipping {n} bytes.") raise EOFError(f"Unexpected EOF while skipping {n} bytes")
remaining -= len(chunk) remaining -= len(chunk)
def _serial_write_from_file(self, f: BinaryIO, nbytes: int, ser: serial.Serial) -> bytes: def _serial_write_from_file(self, f: BinaryIO, nbytes: int, ser: serial.Serial) -> bytes:
@ -313,12 +312,12 @@ class VNADataAcquisition:
to_read = min(cfg.TX_CHUNK_SIZE, remaining) to_read = min(cfg.TX_CHUNK_SIZE, remaining)
chunk = f.read(to_read) chunk = f.read(to_read)
if not chunk: if not chunk:
raise EOFError("Log truncated while sending.") raise EOFError("Log truncated while sending")
# Capture a peek for command inspection # Capture a peek for command inspection
needed = max(0, cfg.SERIAL_PEEK_SIZE - len(first)) need = max(0, cfg.SERIAL_PEEK_SIZE - len(first))
if needed: if need:
first.extend(chunk[:needed]) first.extend(chunk[:need])
# Write to serial # Write to serial
written = 0 written = 0
@ -334,17 +333,16 @@ class VNADataAcquisition:
def _serial_read_exact(self, nbytes: int, ser: serial.Serial, capture: bool = False) -> bytes: def _serial_read_exact(self, nbytes: int, ser: serial.Serial, capture: bool = False) -> bytes:
"""Read exactly *nbytes* from the serial port; optionally capture and return them.""" """Read exactly *nbytes* from the serial port; optionally capture and return them."""
deadline = time.monotonic() + cfg.RX_TIMEOUT deadline = time.monotonic() + cfg.RX_TIMEOUT
total = 0 total = 0
out = bytearray() if capture else None out = bytearray() if capture else None
old_timeout = ser.timeout old_timeout = ser.timeout
ser.timeout = min(cfg.SERIAL_IDLE_TIMEOUT, cfg.RX_TIMEOUT) ser.timeout = min(cfg.SERIAL_IDLE_TIMEOUT, cfg.RX_TIMEOUT)
try: try:
while total < nbytes: while total < nbytes:
if time.monotonic() >= deadline: if time.monotonic() >= deadline:
raise TimeoutError(f"Timeout while waiting for {nbytes} bytes.") raise TimeoutError(f"Timeout while waiting for {nbytes} bytes")
chunk = ser.read(nbytes - total) chunk = ser.read(nbytes - total)
if chunk: if chunk:
total += len(chunk) total += len(chunk)
@ -357,25 +355,18 @@ class VNADataAcquisition:
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
# Parsing & detection # Parsing & detection
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
def _parse_measurement_data(self, payload: bytes) -> list[tuple[float, float]]:
def _parse_measurement_data(self, payload: bytes) -> List[Tuple[float, float]]:
"""Parse complex measurement samples (float32 pairs) from a payload.""" """Parse complex measurement samples (float32 pairs) from a payload."""
if len(payload) <= cfg.MEAS_HEADER_LEN: if len(payload) <= cfg.MEAS_HEADER_LEN:
return [] return []
data = memoryview(payload)[cfg.MEAS_HEADER_LEN:] data = memoryview(payload)[cfg.MEAS_HEADER_LEN:]
out: List[Tuple[float, float]] = [] # Use iter_unpack for speed and clarity
n_pairs = len(data) // 8 # 2 × float32 per point points: list[tuple[float, float]] = []
for real, imag in struct.iter_unpack("<ff", data[: (len(data) // 8) * 8]):
for i in range(n_pairs): points.append((real, imag))
off = i * 8 return points
real = struct.unpack_from("<f", data, off)[0]
imag = struct.unpack_from("<f", data, off + 4)[0]
out.append((real, imag))
return out
def _is_sweep_start_command(self, tx_len: int, first_bytes: bytes) -> bool: def _is_sweep_start_command(self, tx_len: int, first_bytes: bytes) -> bool:
"""Return True if a TX command indicates the start of a sweep.""" """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) return tx_len == cfg.SWEEP_CMD_LEN and first_bytes.startswith(cfg.SWEEP_CMD_PREFIX)

View File

@ -0,0 +1,136 @@
import glob
import serial.tools.list_ports
from vna_system.core.logging.logger import get_component_logger
from vna_system.core.config import VNA_PID, VNA_VID
logger = get_component_logger(__file__)
class VNAPortLocator:
"""
Robust VNA serial port locator with in-memory cache.
Strategy
--------
1) Prefer the cached port if it exists *and* matches expected VID/PID.
2) Scan all ports and pick an exact VID/PID match.
3) As a last resort, pick the first /dev/ttyACM* (unverified).
"""
__slots__ = ("_cached_port")
def __init__(self) -> None:
self._cached_port: str | None = None
logger.debug("VNAPortLocator initialized")
# ---------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------
def _enumerate_ports(self) -> list:
"""Return a list of pyserial ListPortInfo entries."""
try:
ports = list(serial.tools.list_ports.comports())
logger.debug("Serial ports enumerated", count=len(ports))
return ports
except Exception as exc:
logger.warning("Failed to enumerate serial ports", error=repr(exc))
return []
def _get_port_info(self, device: str):
"""Return pyserial ListPortInfo for a device path, or None if absent."""
for p in self._enumerate_ports():
if p.device == device:
return p
return None
@staticmethod
def _is_exact_vna_port(p) -> bool:
"""True if VID/PID match exactly the expected VNA device."""
return getattr(p, "vid", None) == VNA_VID and getattr(p, "pid", None) == VNA_PID
def verify_port_identity(self, port: str) -> bool:
"""
Verify that a device path belongs to *our* VNA by VID/PID.
Returns True only if the device exists and matches VID/PID exactly.
"""
info = self._get_port_info(port)
if not info:
logger.debug("Port not present", device=port)
return False
if self._is_exact_vna_port(info):
logger.debug("Port verified by VID/PID", device=port, vid=info.vid, pid=info.pid)
return True
logger.warning(
"Port belongs to a different device",
device=port,
vid=getattr(info, "vid", None),
pid=getattr(info, "pid", None),
expected_vid=VNA_VID,
expected_pid=VNA_PID,
)
return False
# ---------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------
def find_vna_port(self) -> str | None:
"""
Locate the VNA serial port following the strategy described in the class docstring.
Returns
-------
str | None
Device path if found; otherwise None.
"""
cached = self._cached_port
# 1) Try the cached port (must be present and VID/PID-correct)
if cached and self.verify_port_identity(cached):
logger.info("Using cached VNA port", device=cached)
return cached
elif cached:
logger.debug("Ignoring cached port due to VID/PID mismatch", device=cached)
# 2) Enumerate ports and pick exact VID/PID match (prefer stable identity)
exact_candidates: list[str] = []
for p in self._enumerate_ports():
logger.debug(
"Inspecting port",
device=p.device,
vid=getattr(p, "vid", None),
pid=getattr(p, "pid", None),
manufacturer=getattr(p, "manufacturer", None),
description=getattr(p, "description", None),
)
if self._is_exact_vna_port(p):
exact_candidates.append(p.device)
logger.debug("Exact candidates collected", count=len(exact_candidates), candidates=exact_candidates)
if exact_candidates:
# If the cached path is among exact matches, keep its priority
selected = cached if cached in exact_candidates else exact_candidates[0]
logger.info("VNA device found by VID/PID", device=selected)
self._cached_port = selected
logger.debug("Cached port updated", device=selected)
return selected
# 3) Last resort: first ACM device (best-effort on Linux; not cached)
try:
acm_ports = sorted(glob.glob("/dev/ttyACM*"))
logger.debug("ACM ports scanned", ports=acm_ports)
if acm_ports:
selected = acm_ports[0]
logger.info("Using first available ACM port (unverified)", device=selected)
return selected
except Exception as exc:
logger.warning("Error during ACM port detection", error=repr(exc))
logger.warning("VNA device not found by auto-detection")
return None

View File

@ -1,54 +1,108 @@
import math
import threading
import time
from collections import deque from collections import deque
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Tuple import threading
import time
from vna_system.core.config import SWEEP_BUFFER_MAX_SIZE from vna_system.core.config import SWEEP_BUFFER_MAX_SIZE
from vna_system.core.logging.logger import get_component_logger
logger = get_component_logger(__file__)
Point = tuple[float, float] # (real, imag)
@dataclass @dataclass(slots=True, frozen=True)
class SweepData: class SweepData:
"""Container for a single sweep with metadata""" """
Immutable container for a single sweep with metadata.
Attributes
----------
sweep_number:
Monotonically increasing identifier for the sweep.
timestamp:
UNIX timestamp (seconds since epoch) when the sweep was stored.
points:
Sequence of complex-valued points represented as (real, imag) tuples.
total_points:
Cached number of points in `points` for quick access.
"""
sweep_number: int sweep_number: int
timestamp: float timestamp: float
points: List[Tuple[float, float]] # Complex pairs (real, imag) points: list[Point]
total_points: int 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: class SweepBuffer:
"""Thread-safe circular buffer for sweep data""" """
Thread-safe circular buffer for sweep data.
def __init__(self, max_size: int = SWEEP_BUFFER_MAX_SIZE, initial_sweep_number: int = 0): Parameters
self._buffer = deque(maxlen=max_size) ----------
max_size:
Maximum number of sweeps to retain. Old entries are discarded when the
buffer exceeds this size.
initial_sweep_number:
Starting value for the internal sweep counter.
"""
def __init__(self, max_size: int = SWEEP_BUFFER_MAX_SIZE, initial_sweep_number: int = 0) -> None:
self._buffer: deque[SweepData] = deque(maxlen=max_size)
self._lock = threading.RLock() self._lock = threading.RLock()
self._sweep_counter = initial_sweep_number self._sweep_counter = initial_sweep_number
logger.debug("SweepBuffer initialized", max_size=max_size, initial_sweep_number=initial_sweep_number)
def add_sweep(self, points: List[Tuple[float, float]]) -> int: # ------------------------------
"""Add a new sweep to the buffer and return its number""" # Introspection utilities
# ------------------------------
@property
def current_sweep_number(self) -> int:
"""Return the last assigned sweep number (0 if none were added yet)."""
with self._lock:
logger.debug("Current sweep number retrieved", sweep_number=self._sweep_counter)
return self._sweep_counter
# ------------------------------
# Core API
# ------------------------------
def add_sweep(self, points: list[Point]) -> int:
"""
Add a new sweep to the buffer.
Parameters
----------
points:
Sequence of (real, imag) tuples representing a sweep.
Returns
-------
int
The assigned sweep number for the newly added sweep.
"""
timestamp = time.time()
with self._lock: with self._lock:
self._sweep_counter += 1 self._sweep_counter += 1
sweep = SweepData( sweep = SweepData(
sweep_number=self._sweep_counter, sweep_number=self._sweep_counter,
timestamp=time.time(), timestamp=timestamp,
points=points, points=list(points), # ensure we store our own list
total_points=len(points) total_points=len(points),
) )
self._buffer.append(sweep) self._buffer.append(sweep)
logger.debug(
"New sweep added",
sweep_number=sweep.sweep_number,
total_points=sweep.total_points,
buffer_size=len(self._buffer),
)
return self._sweep_counter return self._sweep_counter
def get_latest_sweep(self) -> SweepData | None: def get_latest_sweep(self) -> SweepData | None:
"""Get the most recent sweep""" """Return the most recent sweep, or None if the buffer is empty."""
with self._lock: with self._lock:
return self._buffer[-1] if self._buffer else None sweep = self._buffer[-1] if self._buffer else None
# if sweep: # TOO NOISY
# logger.debug("Latest sweep retrieved", sweep_number=sweep.sweep_number)
# else:
# logger.debug("Latest sweep requested but buffer is empty")
return sweep

View File

@ -1,124 +1,76 @@
#!/usr/bin/env python3
"""
Configuration file for VNA data acquisition system
"""
import glob
import logging
from pathlib import Path from pathlib import Path
import serial.tools.list_ports
# Base directory for VNA system # -----------------------------------------------------------------------------
# Project paths
# -----------------------------------------------------------------------------
BASE_DIR = Path(__file__).parent.parent BASE_DIR = Path(__file__).parent.parent
# Serial communication settings # -----------------------------------------------------------------------------
DEFAULT_BAUD_RATE = 115200 # API / Server settings
DEFAULT_PORT = "/dev/ttyACM0" # -----------------------------------------------------------------------------
API_HOST = "0.0.0.0"
API_PORT = 8000
# VNA device identification # -----------------------------------------------------------------------------
VNA_VID = 0x0483 # STMicroelectronics # Logging settings (используются из main)
VNA_PID = 0x5740 # STM32 Virtual ComPort # -----------------------------------------------------------------------------
VNA_MANUFACTURER = "STMicroelectronics" LOG_LEVEL = "INFO" # {"DEBUG","INFO","WARNING","ERROR","CRITICAL"}
VNA_PRODUCT = "STM32 Virtual ComPort" LOG_DIR = BASE_DIR / "logs" # Directory for application logs
LOG_APP_FILE = LOG_DIR / "vna_system.log" # Main application log file
# -----------------------------------------------------------------------------
# Serial communication settings
# -----------------------------------------------------------------------------
DEFAULT_BAUD_RATE = 115200
RX_TIMEOUT = 5.0 RX_TIMEOUT = 5.0
TX_CHUNK_SIZE = 64 * 1024 TX_CHUNK_SIZE = 64 * 1024
# -----------------------------------------------------------------------------
# VNA device identification
# -----------------------------------------------------------------------------
VNA_VID = 0x0483 # STMicroelectronics
VNA_PID = 0x5740 # STM32 Virtual ComPort
# -----------------------------------------------------------------------------
# Sweep detection and parsing constants # Sweep detection and parsing constants
# -----------------------------------------------------------------------------
SWEEP_CMD_LEN = 515 SWEEP_CMD_LEN = 515
SWEEP_CMD_PREFIX = bytes([0xAA, 0x00, 0xDA]) SWEEP_CMD_PREFIX = bytes([0xAA, 0x00, 0xDA])
MEAS_HEADER_LEN = 21 MEAS_HEADER_LEN = 21
MEAS_CMDS_PER_SWEEP = 17 MEAS_CMDS_PER_SWEEP = 17
EXPECTED_POINTS_PER_SWEEP = 1000 EXPECTED_POINTS_PER_SWEEP = 1000
# -----------------------------------------------------------------------------
# Buffer settings # Buffer settings
# -----------------------------------------------------------------------------
SWEEP_BUFFER_MAX_SIZE = 100 # Maximum number of sweeps to store in circular buffer SWEEP_BUFFER_MAX_SIZE = 100 # Maximum number of sweeps to store in circular buffer
SERIAL_BUFFER_SIZE = 512 * 1024 SERIAL_BUFFER_SIZE = 512 * 1024
# Log file settings # -----------------------------------------------------------------------------
BIN_INPUT_FILE_PATH = "./vna_system/binary_input/current_input.bin" # Symbolic link to the current log file # Log file settings (binary input path, not to be confused with text logs)
# -----------------------------------------------------------------------------
BIN_INPUT_FILE_PATH = "./vna_system/binary_input/current_input.bin" # Symlink to current binary input
# -----------------------------------------------------------------------------
# Binary log format constants # Binary log format constants
# -----------------------------------------------------------------------------
MAGIC = b"VNALOG1\n" MAGIC = b"VNALOG1\n"
DIR_TO_DEV = 0x01 # '>' DIR_TO_DEV = 0x01 # '>'
DIR_FROM_DEV = 0x00 # '<' DIR_FROM_DEV = 0x00 # '<'
# -----------------------------------------------------------------------------
# File I/O settings # File I/O settings
# -----------------------------------------------------------------------------
FILE_CHUNK_SIZE = 256 * 1024 FILE_CHUNK_SIZE = 256 * 1024
SERIAL_PEEK_SIZE = 32 SERIAL_PEEK_SIZE = 32
# -----------------------------------------------------------------------------
# Timeout settings # Timeout settings
# -----------------------------------------------------------------------------
SERIAL_IDLE_TIMEOUT = 0.5 SERIAL_IDLE_TIMEOUT = 0.5
SERIAL_DRAIN_DELAY = 0.05 SERIAL_DRAIN_DELAY = 0.05
SERIAL_DRAIN_CHECK_DELAY = 0.01 SERIAL_DRAIN_CHECK_DELAY = 0.01
SERIAL_CONNECT_DELAY = 0.01 SERIAL_CONNECT_DELAY = 0.01
PROCESSORS_CONFIG_DIR_PATH = "vna_system/core/processors/configs"
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}")
# Check by VID/PID
if port.vid == VNA_VID and port.pid == VNA_PID:
logger.debug(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.debug(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

View File

@ -0,0 +1,9 @@
"""
VNA System Logging Module
Provides centralized, consistent logging across all system components.
"""
from .logger import get_logger, get_component_logger, setup_logging, VNALogger
__all__ = ['get_logger', 'get_component_logger', 'setup_logging', 'VNALogger']

View File

@ -0,0 +1,257 @@
import logging
import sys
from enum import StrEnum
from pathlib import Path
from datetime import datetime
from typing import Any
class LogLevel(StrEnum):
"""Log level enumeration with associated prefixes and ANSI colors."""
DEBUG = "DEBUG"
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
CRITICAL = "CRITICAL"
@property
def prefix(self) -> str:
return {
LogLevel.DEBUG: "DEBUG",
LogLevel.INFO: "INFO",
LogLevel.WARNING: "WARN",
LogLevel.ERROR: "ERROR",
LogLevel.CRITICAL: "FATAL",
}[self]
@property
def color(self) -> str:
# ANSI colors; disabled if stdout is not a TTY
if not sys.stdout.isatty():
return ""
return {
LogLevel.DEBUG: "\033[36m", # Cyan
LogLevel.INFO: "\033[32m", # Green
LogLevel.WARNING: "\033[33m", # Yellow
LogLevel.ERROR: "\033[31m", # Red
LogLevel.CRITICAL: "\033[35m", # Magenta
}[self]
@staticmethod
def reset_color() -> str:
return "" if not sys.stdout.isatty() else "\033[0m"
class VNALogger:
"""
Enhanced logger for VNA system with consistent formatting and optional colors.
Features
--------
- Consistent color coding across all modules (TTY-aware).
- Component name namespacing (logger name: `vna.<component>`).
- Optional file logging per component.
- Lightweight performance timers.
- Structured metadata via keyword arguments.
"""
__slots__ = ("component_name", "log_file", "_logger", "_timers")
_loggers: dict[str, "VNALogger"] = {}
_base_config_set = False
def __init__(self, component_name: str, log_file: Path | None = None) -> None:
self.component_name = component_name
self.log_file = log_file
self._logger = logging.getLogger(f"vna.{component_name}")
self._logger.setLevel(logging.DEBUG)
self._logger.propagate = True # use root handlers configured once
self._timers: dict[str, float] = {}
if not VNALogger._base_config_set:
self._configure_base_logging()
VNALogger._base_config_set = True
if self.log_file:
self._add_file_handler(self.log_file)
# ---------------------------------------------------------------------
# Base configuration (root logger + console format)
# ---------------------------------------------------------------------
def _configure_base_logging(self) -> None:
root = logging.getLogger()
root.setLevel(logging.DEBUG)
# Remove existing handlers to avoid duplicates on reloads
for h in root.handlers[:]:
root.removeHandler(h)
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.DEBUG)
console_handler.setFormatter(self._create_console_formatter())
root.addHandler(console_handler)
def _create_console_formatter(self) -> logging.Formatter:
class ColoredFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
level = LogLevel(record.levelname) if record.levelname in LogLevel.__members__ else LogLevel.INFO
timestamp = datetime.fromtimestamp(record.created).strftime("%H:%M:%S.%f")[:-3]
component = f"[{record.name.replace('vna.', '')}]"
color = level.color
reset = LogLevel.reset_color()
# Use record.getMessage() to apply %-formatting already handled by logging
return f"{color}{level.prefix} {timestamp} {component:<20} {record.getMessage()}{reset}"
return ColoredFormatter()
# ---------------------------------------------------------------------
# File handler
# ---------------------------------------------------------------------
def _add_file_handler(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
# Avoid adding duplicate file handlers for the same path
for h in self._logger.handlers:
if isinstance(h, logging.FileHandler) and Path(h.baseFilename) == path:
return
file_handler = logging.FileHandler(path, encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
self._logger.addHandler(file_handler)
# ---------------------------------------------------------------------
# Public logging API
# ---------------------------------------------------------------------
def debug(self, message: str, /, **metadata: Any) -> None:
self._log_with_metadata(logging.DEBUG, message, metadata)
def info(self, message: str, /, **metadata: Any) -> None:
self._log_with_metadata(logging.INFO, message, metadata)
def warning(self, message: str, /, **metadata: Any) -> None:
self._log_with_metadata(logging.WARNING, message, metadata)
def error(self, message: str, /, **metadata: Any) -> None:
self._log_with_metadata(logging.ERROR, message, metadata)
def critical(self, message: str, /, **metadata: Any) -> None:
self._log_with_metadata(logging.CRITICAL, message, metadata)
def _log_with_metadata(self, level: int, message: str, metadata: dict[str, Any]) -> None:
if metadata:
# Render key=value; repr() helps keep types unambiguous in logs
meta_str = " ".join(f"{k}={repr(v)}" for k, v in metadata.items())
self._logger.log(level, f"{message} | {meta_str}")
else:
self._logger.log(level, message)
# ---------------------------------------------------------------------
# Timers
# ---------------------------------------------------------------------
def timer_start(self, operation: str) -> str:
"""
Start a high-resolution timer for performance measurement.
Returns
-------
str
Timer identifier to be passed to `timer_end`.
"""
# perf_counter() is monotonic & high-resolution
timer_id = f"{self.component_name}:{operation}:{datetime.now().timestamp()}"
self._timers[timer_id] = datetime.now().timestamp()
self.debug("Timer started", operation=operation, timer_id=timer_id)
return timer_id
def timer_end(self, timer_id: str, operation: str | None = None) -> float:
"""
End a timer and log the elapsed time.
Returns
-------
float
Elapsed time in milliseconds. Returns 0.0 if timer_id is unknown.
"""
started = self._timers.pop(timer_id, None)
if started is None:
self.warning("Timer not found", timer_id=timer_id)
return 0.0
elapsed_ms = (datetime.now().timestamp() - started) * 1000.0
self.info("Timer completed", operation=operation or "operation", timer_id=timer_id, elapsed_ms=round(elapsed_ms, 2))
return elapsed_ms
def get_logger(component_name: str, log_file: Path | None = None) -> VNALogger:
"""
Get or create a logger instance for a component.
Examples
--------
>>> logger = get_logger("magnitude_processor")
>>> logger.info("Processor initialized")
"""
cache_key = f"{component_name}|{log_file}"
logger = VNALogger._loggers.get(cache_key)
if logger is None:
logger = VNALogger(component_name, log_file)
VNALogger._loggers[cache_key] = logger
return logger
def get_component_logger(component_path: str) -> VNALogger:
"""
Create a logger with a component name derived from a file path.
The base name of the file (without extension) is used, with a few
opinionated adjustments for readability.
"""
path = Path(component_path)
component = path.stem
if "processor" in component and not component.endswith("_processor"):
component = f"{component}_processor"
elif path.parent.name in {"websocket", "acquisition", "settings"}:
component = f"{path.parent.name}_{component}"
return get_logger(component)
def setup_logging(log_level: str = "INFO", log_dir: Path | None = None) -> None:
"""
Configure application-wide logging defaults.
Parameters
----------
log_level:
One of {"DEBUG","INFO","WARNING","ERROR","CRITICAL"} (case-insensitive).
log_dir:
If provided, creates a rotating file per component on demand.
"""
level_name = log_level.upper()
numeric_level = getattr(logging, level_name, None)
if not isinstance(numeric_level, int):
raise ValueError(f"Invalid log level: {log_level}")
logging.getLogger("vna").setLevel(numeric_level)
# Add global file handler for all logs if log_dir provided
if log_dir:
log_dir.mkdir(parents=True, exist_ok=True)
global_log_file = log_dir / "vna_all.log"
root = logging.getLogger()
# Check if file handler already exists
has_file_handler = any(isinstance(h, logging.FileHandler) for h in root.handlers)
if not has_file_handler:
file_handler = logging.FileHandler(global_log_file, encoding="utf-8")
file_handler.setLevel(numeric_level)
file_handler.setFormatter(logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
))
root.addHandler(file_handler)
# Touch the app logger (and its file) early to confirm configuration.
log_path = (log_dir / "vna_system.log") if log_dir else None
app_logger = get_logger("vna_system", log_path)
app_logger.info("VNA System logging initialized", log_level=level_name, log_dir=str(log_dir) if log_dir else None)

View File

@ -1,190 +1,562 @@
from abc import ABC, abstractmethod from dataclasses import dataclass, asdict
from typing import Dict, Any, List, Optional
from dataclasses import dataclass
from pathlib import Path
import json
import threading
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import Any
import json
import math
import threading
from vna_system.core.logging.logger import get_component_logger
from vna_system.core.settings.preset_manager import ConfigPreset from vna_system.core.settings.preset_manager import ConfigPreset
logger = get_component_logger(__file__)
@dataclass
# =============================================================================
# Data models
# =============================================================================
@dataclass(slots=True)
class UIParameter: class UIParameter:
"""
Descriptor of a single UI control that also serves as a *schema* for config validation.
Fields
------
name:
Stable key used in configs and payloads.
label:
Human-readable label for the UI.
type:
One of: "slider", "toggle", "select", "input", "button".
value:
Default value (also used to seed a missing config key).
options:
Extra, type-specific metadata used for validation and UI behavior.
Supported `options` by type
---------------------------
slider:
{"min": <number>, "max": <number>, "step": <number>, "dtype": "int"|"float"}
Notes:
- dtype defaults to "float".
- step alignment is checked from `min` when provided, otherwise from 0/0.0.
toggle:
{}
select:
{"choices": [<allowed_value>, ...]}
Notes:
- `choices` MUST be present and must be a list.
input:
{"type": "int"|"float", "min": <number>, "max": <number>}
Notes:
- Strings are NOT allowed; only numeric input is accepted.
- `type` is required and controls casting/validation.
button:
{"action": "<human readable action>"}
Notes:
- The button value is ignored by validation (buttons are commands, not state).
"""
name: str name: str
label: str label: str
type: str # 'slider', 'toggle', 'select', 'input', 'button' type: str
value: Any value: Any
options: Optional[Dict[str, Any]] = None # min/max for slider, choices for select, etc. options: dict[str, Any] | None = None
@dataclass @dataclass(slots=True)
class ProcessedResult: class ProcessedResult:
"""
Result payload emitted by processors.
Fields
------
processor_id:
Logical processor identifier.
timestamp:
UNIX timestamp (float) when the result was produced.
data:
Arbitrary computed data (domain-specific).
plotly_config:
Prebuilt Plotly figure/config to render on the client.
ui_parameters:
The UI schema (possibly dynamic) that the client can render.
metadata:
Additional context useful to the UI or debugging (e.g., config snapshot).
"""
processor_id: str processor_id: str
timestamp: float timestamp: float
data: Dict[str, Any] data: dict[str, Any]
plotly_config: Dict[str, Any] plotly_config: dict[str, Any]
ui_parameters: List[UIParameter] ui_parameters: list[UIParameter]
metadata: Dict[str, Any] metadata: dict[str, Any]
class BaseProcessor(ABC): # =============================================================================
def __init__(self, processor_id: str, config_dir: Path): # Base processor
# =============================================================================
class BaseProcessor:
"""
Base class for sweep processors.
Responsibilities
----------------
• Manage a JSON config file (load → validate → save).
• Keep a bounded, thread-safe history of recent sweeps.
• Provide a uniform API for (re)calculation and result packaging.
• Validate config against the UI schema provided by `get_ui_parameters()`.
Integration contract (to be implemented by subclasses)
------------------------------------------------------
- `process_sweep(sweep_data, calibrated_data, vna_config) -> dict[str, Any]`
Perform the actual computation and return a pure-data dict.
- `generate_plotly_config(processed_data, vna_config) -> dict[str, Any]`
Convert computed data to a Plotly config the client can render.
- `get_ui_parameters() -> list[UIParameter]`
Provide the UI schema (and validation rules via `options`) for this processor.
- `_get_default_config() -> dict[str, Any]`
Provide defaults for config keys. Keys should match UIParameter names.
"""
# --------------------------------------------------------------------- #
# Lifecycle
# --------------------------------------------------------------------- #
def __init__(self, processor_id: str, config_dir: Path) -> None:
self.processor_id = processor_id self.processor_id = processor_id
self.config_dir = config_dir self.config_dir = config_dir
self.config_file = config_dir / f"{processor_id}_config.json" self.config_file = config_dir / f"{processor_id}_config.json"
self._lock = threading.RLock()
self._sweep_history: List[Any] = []
self._max_history = 1
self._config = {}
self._load_config()
# Concurrency: all shared state guarded by this lock
self._lock = threading.RLock()
# Bounded history of recent inputs/results (data-only dicts)
self._sweep_history: list[dict[str, Any]] = []
self._max_history = 1
# Current configuration snapshot
self._config: dict[str, Any] = {}
self._load_config()
logger.debug(
"Processor initialized",
processor_id=self.processor_id,
config_file=str(self.config_file),
)
# --------------------------------------------------------------------- #
# History management
# --------------------------------------------------------------------- #
@property @property
def max_history(self) -> int: def max_history(self) -> int:
"""Maximum number of history entries retained in memory."""
return self._max_history return self._max_history
@max_history.setter @max_history.setter
def max_history(self, value: int): def max_history(self, value: int) -> None:
"""Change max history size (min 1) and trim existing history if needed."""
with self._lock: with self._lock:
self._max_history = max(1, value) new_size = max(1, int(value))
self._trim_history() if new_size != self._max_history:
self._max_history = new_size
self._trim_history()
logger.debug(
"Max history updated",
max_history=new_size,
current=len(self._sweep_history),
)
def _trim_history(self): def clear_history(self) -> None:
"""Drop all stored history entries."""
with self._lock:
self._sweep_history.clear()
logger.debug("History cleared")
def _trim_history(self) -> None:
"""Internal: keep only the newest `_max_history` items."""
if len(self._sweep_history) > self._max_history: if len(self._sweep_history) > self._max_history:
dropped = len(self._sweep_history) - self._max_history
self._sweep_history = self._sweep_history[-self._max_history:] self._sweep_history = self._sweep_history[-self._max_history:]
logger.debug("History trimmed", dropped=dropped, kept=self._max_history)
def _load_config(self): # --------------------------------------------------------------------- #
# Config I/O and updates
# --------------------------------------------------------------------- #
def _load_config(self) -> None:
"""
Load the JSON config from disk; on failure or first run, use defaults.
Strategy
--------
- Read file if present; ensure the root is a dict.
- Shallow-merge with defaults (unknown keys are preserved).
- Validate using UI schema (`_validate_config`).
- On any error, fall back to defaults and save them.
"""
defaults = self._get_default_config()
if self.config_file.exists(): if self.config_file.exists():
try: try:
with open(self.config_file, 'r') as f: cfg = json.loads(self.config_file.read_text(encoding="utf-8"))
self._config = json.load(f) if not isinstance(cfg, dict):
raise ValueError("Config root must be an object")
merged = {**defaults, **cfg}
self._config = merged
self._validate_config() self._validate_config()
except (json.JSONDecodeError, FileNotFoundError): logger.debug("Config loaded", file=str(self.config_file))
self._config = self._get_default_config() return
self.save_config() except Exception as exc: # noqa: BLE001
else: logger.warning(
self._config = self._get_default_config() "Config load failed; using defaults",
self.save_config() error=repr(exc),
file=str(self.config_file),
)
def save_config(self): self._config = defaults
self.save_config()
def save_config(self) -> None:
"""
Save current config to disk atomically.
Implementation detail
---------------------
Write to a temporary sidecar file and then replace the target to avoid
partial writes in case of crashes.
"""
self.config_dir.mkdir(parents=True, exist_ok=True) self.config_dir.mkdir(parents=True, exist_ok=True)
with open(self.config_file, 'w') as f: tmp = self.config_file.with_suffix(".json.tmp")
json.dump(self._config, f, indent=2) payload = json.dumps(self._config, indent=2, ensure_ascii=False)
tmp.write_text(payload, encoding="utf-8")
tmp.replace(self.config_file)
logger.debug("Config saved", file=str(self.config_file))
def update_config(self, updates: Dict[str, Any]): def update_config(self, updates: dict[str, Any]) -> None:
"""
Update config with user-provided values.
- Performs type conversion based on current schema (`_convert_config_types`).
- Validates against UI schema; on failure rolls back to the previous state.
- Saves the resulting config when validation passes.
"""
with self._lock: with self._lock:
old_config = self._config.copy() before = self._config.copy()
# Convert types based on existing config values converted = self._convert_config_types(updates)
converted_updates = self._convert_config_types(updates) self._config.update(converted)
self._config.update(converted_updates)
try: try:
self._validate_config() self._validate_config()
self.save_config() self.save_config()
except Exception as e: logger.info("Config updated", updates=converted)
self._config = old_config except Exception as exc: # noqa: BLE001
raise ValueError(f"Invalid configuration: {e}") self._config = before
logger.error("Invalid configuration update; rolled back", error=repr(exc))
raise ValueError(f"Invalid configuration: {exc}") from exc
def _convert_config_types(self, updates: Dict[str, Any]) -> Dict[str, Any]: def get_config(self) -> dict[str, Any]:
"""Convert string values to appropriate types based on existing config""" """Return a shallow copy of the current config snapshot."""
converted = {}
for key, value in updates.items():
# If the key is not in the current config, keep the value as-is
if key not in self._config:
converted[key] = value
continue
existing_value = self._config[key]
# Convert booleans from string
if isinstance(existing_value, bool) and isinstance(value, str):
converted[key] = value.lower() in ('true', '1', 'on', 'yes')
continue
# Convert numbers from string
if isinstance(existing_value, (int, float)) and isinstance(value, str):
try:
if isinstance(existing_value, int):
# Handle cases like "50.0" → 50
converted[key] = int(float(value))
else:
converted[key] = float(value)
except ValueError:
# Keep the original string if conversion fails
converted[key] = value
continue
# For all other cases, keep the value as-is
converted[key] = value
return converted
def get_config(self) -> Dict[str, Any]:
return self._config.copy()
def add_sweep_data(self, sweep_data: Any, calibrated_data: Any, vna_config: ConfigPreset | None):
with self._lock: with self._lock:
self._sweep_history.append({ return self._config.copy()
'sweep_data': sweep_data,
'calibrated_data': calibrated_data, def _convert_config_types(self, updates: dict[str, Any]) -> dict[str, Any]:
'vna_config': vna_config.__dict__ if vna_config is not None else {}, """
'timestamp': datetime.now().timestamp() Convert string inputs into the target types inferred from the current config.
})
Rules
-----
• Booleans: accept case-insensitive {"true","1","on","yes"}.
• Int/float: accept numeric strings; for ints, tolerate "50.0" → 50.
• Unknown keys: kept as-is (subclass validators may use them).
"""
out: dict[str, Any] = {}
for key, value in updates.items():
if key not in self._config:
out[key] = value
continue
current = self._config[key]
# bool
if isinstance(current, bool) and isinstance(value, str):
out[key] = value.strip().lower() in {"true", "1", "on", "yes"}
continue
# numbers
if isinstance(current, int) and isinstance(value, str):
try:
out[key] = int(float(value))
continue
except ValueError:
pass
if isinstance(current, float) and isinstance(value, str):
try:
out[key] = float(value)
continue
except ValueError:
pass
# fallback: unchanged
out[key] = value
return out
# --------------------------------------------------------------------- #
# Data path: accept new sweep, recompute, produce result
# --------------------------------------------------------------------- #
def add_sweep_data(self, sweep_data: Any, calibrated_data: Any, vna_config: ConfigPreset | None):
"""
Add the latest sweep to the in-memory history and trigger recalculation.
Parameters
----------
sweep_data:
Raw/parsed sweep data as produced by acquisition.
calibrated_data:
Data post-calibration (structure is processor-specific).
vna_config:
Snapshot of VNA settings (dataclass or pydantic model supported).
Returns
-------
ProcessedResult | None
The newly computed result or None when history is empty.
"""
with self._lock:
self._sweep_history.append(
{
"sweep_data": sweep_data,
"calibrated_data": calibrated_data,
"vna_config": asdict(vna_config) if vna_config is not None else {},
"timestamp": datetime.now().timestamp(),
}
)
self._trim_history() self._trim_history()
return self.recalculate() return self.recalculate()
def recalculate(self) -> Optional[ProcessedResult]: def recalculate(self) -> ProcessedResult | None:
"""
Recompute the processor output using the most recent history entry.
Notes
-----
Subclasses must ensure `process_sweep` and `generate_plotly_config`
are pure (no global side effects) and thread-safe w.r.t. the provided inputs.
"""
with self._lock: with self._lock:
if not self._sweep_history: if not self._sweep_history:
logger.debug("Recalculate skipped; history empty")
return None return None
latest = self._sweep_history[-1] latest = self._sweep_history[-1]
return self._process_data( return self._process_data(
latest['sweep_data'], latest["sweep_data"],
latest['calibrated_data'], latest["calibrated_data"],
latest['vna_config'] latest["vna_config"],
) )
def _process_data(self, sweep_data: Any, calibrated_data: Any, vna_config: Dict[str, Any]) -> ProcessedResult: def _process_data(self, sweep_data: Any, calibrated_data: Any, vna_config: dict[str, Any]) -> ProcessedResult:
processed_data = self.process_sweep(sweep_data, calibrated_data, vna_config) """
plotly_config = self.generate_plotly_config(processed_data, vna_config) Internal: compute processed data, build a Plotly config, and wrap into `ProcessedResult`.
ui_parameters = self.get_ui_parameters() """
processed = self.process_sweep(sweep_data, calibrated_data, vna_config)
plotly_conf = self.generate_plotly_config(processed, vna_config)
ui_params = self.get_ui_parameters()
return ProcessedResult( result = ProcessedResult(
processor_id=self.processor_id, processor_id=self.processor_id,
timestamp=datetime.now().timestamp(), timestamp=datetime.now().timestamp(),
data=processed_data, data=processed,
plotly_config=plotly_config, plotly_config=plotly_conf,
ui_parameters=ui_parameters, ui_parameters=ui_params,
metadata=self._get_metadata() metadata=self._get_metadata(),
) )
logger.debug("Processed result produced", processor_id=self.processor_id)
return result
@abstractmethod # --------------------------------------------------------------------- #
def process_sweep(self, sweep_data: Any, calibrated_data: Any, vna_config: Dict[str, Any]) -> Dict[str, Any]: # Abstracts to implement in concrete processors
pass # --------------------------------------------------------------------- #
def process_sweep(self, sweep_data: Any, calibrated_data: Any, vna_config: dict[str, Any]) -> dict[str, Any]:
"""Compute the processors domain result from input sweep data."""
raise NotImplementedError
@abstractmethod def generate_plotly_config(self, processed_data: dict[str, Any], vna_config: dict[str, Any]) -> dict[str, Any]:
def generate_plotly_config(self, processed_data: Dict[str, Any], vna_config: Dict[str, Any]) -> Dict[str, Any]: """Create a ready-to-render Plotly configuration from processed data."""
pass raise NotImplementedError
@abstractmethod def get_ui_parameters(self) -> list[UIParameter]:
def get_ui_parameters(self) -> List[UIParameter]: """Return the UI schema (used both for UI rendering and config validation)."""
pass raise NotImplementedError
@abstractmethod def _get_default_config(self) -> dict[str, Any]:
def _get_default_config(self) -> Dict[str, Any]: """Provide default config values; keys should match `UIParameter.name`."""
pass raise NotImplementedError
@abstractmethod # --------------------------------------------------------------------- #
def _validate_config(self): # Validation using UI schema
pass # --------------------------------------------------------------------- #
def _validate_config(self) -> None:
"""
Validate `self._config` using the schema in `get_ui_parameters()`.
def _get_metadata(self) -> Dict[str, Any]: Validation rules
return { ----------------
'processor_id': self.processor_id, slider:
'config': self._config, - dtype: "int"|"float" (default "float")
'history_count': len(self._sweep_history), - min/max: inclusive numeric bounds
'max_history': self._max_history - step: if present, enforce alignment from `min` (or 0/0.0)
} toggle:
- must be bool
select:
- options must be {"choices": [ ... ]}
- value must be one of `choices`
input:
- options must be {"type": "int"|"float", "min"?, "max"?}
- value must be numeric (no strings)
button:
- options must be {"action": "<text>"}
- value is ignored by validation
Unknown control types emit a warning but do not block execution.
"""
params = {p.name: p for p in self.get_ui_parameters()}
for name, schema in params.items():
if name not in self._config:
# Seed missing keys from the UI default to maintain a consistent shape.
self._config[name] = schema.value
logger.debug("Config key missing; seeded from UI default", key=name, value=schema.value)
value = self._config[name]
ptype = (schema.type or "").lower()
opts = schema.options or {}
try:
if ptype == "slider":
self._validate_slider(name, value, opts)
elif ptype == "toggle":
self._validate_toggle(name, value)
elif ptype == "select":
self._validate_select_strict(name, value, opts)
elif ptype == "input":
self._validate_input_numeric(name, value, opts)
elif ptype == "button":
self._validate_button_opts(name, opts)
else:
logger.warning("Unknown UI control type; skipping validation", key=name, type=ptype)
except ValueError as exc:
# Prefix the processor id for easier debugging in multi-processor UIs.
raise ValueError(f"[{self.processor_id}] Invalid `{name}`: {exc}") from exc
# ---- Validators (per control type) ----------------------------------- #
def _validate_slider(self, key: str, value: Any, opts: dict[str, Any]) -> None:
"""
Validate a slider value; normalize to int/float based on `dtype`.
"""
dtype = str(opts.get("dtype", "float")).lower()
min_v = opts.get("min")
max_v = opts.get("max")
step = opts.get("step")
# Type / casting
if dtype == "int":
if not isinstance(value, int):
if isinstance(value, float) and value.is_integer():
value = int(value)
else:
raise ValueError(f"expected int, got {type(value).__name__}")
else:
if not isinstance(value, (int, float)):
raise ValueError(f"expected number, got {type(value).__name__}")
value = float(value)
# Bounds
if min_v is not None and value < min_v:
raise ValueError(f"{value} < min {min_v}")
if max_v is not None and value > max_v:
raise ValueError(f"{value} > max {max_v}")
# Step alignment
if step is not None:
base = min_v if min_v is not None else (0 if dtype == "int" else 0.0)
if dtype == "int":
if (value - int(base)) % int(step) != 0:
raise ValueError(f"value {value} not aligned to step {step} from base {base}")
else:
steps = (value - float(base)) / float(step)
if not math.isclose(steps, round(steps), rel_tol=1e-9, abs_tol=1e-9):
raise ValueError(f"value {value} not aligned to step {step} from base {base}")
# Normalize the stored value
self._config[key] = int(value) if dtype == "int" else float(value)
def _validate_toggle(self, key: str, value: Any) -> None:
"""Validate a boolean toggle."""
if not isinstance(value, bool):
raise ValueError(f"expected bool, got {type(value).__name__}")
def _validate_select_strict(self, key: str, value: Any, opts: dict[str, Any]) -> None:
"""
Validate a 'select' value against a required list of choices.
"""
if not isinstance(opts, dict) or "choices" not in opts or not isinstance(opts["choices"], list):
raise ValueError("select.options must be a dict with key 'choices' as a list")
choices = opts["choices"]
if value not in choices:
raise ValueError(f"value {value!r} not in choices {choices!r}")
def _validate_input_numeric(self, key: str, value: Any, opts: dict[str, Any]) -> None:
"""
Validate a numeric input.
options:
- type: "int" | "float" (required)
- min/max: optional numeric bounds
"""
t = str(opts.get("type", "")).lower()
if t not in {"int", "float"}:
raise ValueError("input.options.type must be 'int' or 'float'")
if t == "int":
# bool is a subclass of int in Python; explicitly reject it
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"expected int, got {type(value).__name__}")
if isinstance(value, float) and not value.is_integer():
raise ValueError(f"expected int, got non-integer float {value}")
iv = int(value)
self._numeric_bounds_check(key, iv, opts)
self._config[key] = iv
else:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"expected float, got {type(value).__name__}")
fv = float(value)
self._numeric_bounds_check(key, fv, opts)
self._config[key] = fv
def _validate_button_opts(self, key: str, opts: dict[str, Any]) -> None:
"""
Validate a button descriptor; buttons are imperative actions, not state.
"""
if not isinstance(opts, dict) or "action" not in opts or not isinstance(opts["action"], str):
raise ValueError("button.options must be a dict with key 'action' (str)")
@staticmethod
def _numeric_bounds_check(key: str, value: float, opts: dict[str, Any]) -> None:
"""Shared numeric bounds check for input/slider."""
min_v = opts.get("min")
max_v = opts.get("max")
if min_v is not None and value < min_v:
raise ValueError(f"{key} {value} < min {min_v}")
if max_v is not None and value > max_v:
raise ValueError(f"{key} {value} > max {max_v}")
# --------------------------------------------------------------------- #
# Utilities
# --------------------------------------------------------------------- #
def _get_metadata(self) -> dict[str, Any]:
"""
Return diagnostic metadata bundled with each `ProcessedResult`.
"""
with self._lock:
return {
"processor_id": self.processor_id,
"config": self._config.copy(),
"history_count": len(self._sweep_history),
"max_history": self._max_history,
}

View File

@ -1,134 +1,136 @@
"""
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 import numpy as np
from typing import List, Tuple
from ..acquisition.sweep_buffer import SweepData from vna_system.core.logging.logger import get_component_logger
from ..settings.preset_manager import VNAMode from vna_system.core.acquisition.sweep_buffer import SweepData
from ..settings.calibration_manager import CalibrationSet, CalibrationStandard from vna_system.core.settings.preset_manager import VNAMode
from vna_system.core.settings.calibration_manager import CalibrationSet, CalibrationStandard
logger = get_component_logger(__file__)
class CalibrationProcessor: class CalibrationProcessor:
""" """
Processes sweep data by applying VNA calibrations. Apply VNA calibration to raw sweeps.
For S11 mode: Uses OSL (Open-Short-Load) calibration Supports:
For S21 mode: Uses Through calibration - S11 (reflection) using OSL (OpenShortLoad) error model
- S21 (transmission) using THRU reference
All operations are vectorized with NumPy and return data as a list of (real, imag) tuples.
""" """
def __init__(self): def apply_calibration(self, sweep_data: SweepData, calibration_set: CalibrationSet) -> list[tuple[float, float]]:
pass
def apply_calibration(self, sweep_data: SweepData, calibration_set: CalibrationSet) -> List[Tuple[float, float]]:
""" """
Apply calibration to sweep data and return corrected complex data as list of (real, imag) tuples. Calibrate a sweep and return corrected complex points.
Args: Parameters
sweep_data: Raw sweep data from VNA ----------
calibration_set: Calibration standards data sweep_data
Raw sweep as (real, imag) tuples with metadata.
calibration_set
A complete set of standards for the current VNA mode.
Returns: Returns
List of (real, imag) tuples with calibration applied -------
list[tuple[float, float]]
Calibrated complex points as (real, imag) pairs.
Raises: Raises
ValueError: If calibration is incomplete or mode not supported ------
ValueError
If the calibration set is incomplete or the VNA mode is unsupported.
""" """
if not calibration_set.is_complete(): if not calibration_set.is_complete():
raise ValueError("Calibration set is incomplete") raise ValueError("Calibration set is incomplete")
# Convert sweep data to complex array raw = self._to_complex_array(sweep_data)
raw_signal = self._sweep_to_complex_array(sweep_data)
# Apply calibration based on measurement mode
if calibration_set.preset.mode == VNAMode.S21: if calibration_set.preset.mode == VNAMode.S21:
calibrated_array = self._apply_s21_calibration(raw_signal, calibration_set) logger.debug("Applying S21 calibration", sweep_number=sweep_data.sweep_number, points=sweep_data.total_points)
calibrated = self._apply_s21(raw, calibration_set)
elif calibration_set.preset.mode == VNAMode.S11: elif calibration_set.preset.mode == VNAMode.S11:
calibrated_array = self._apply_s11_calibration(raw_signal, calibration_set) logger.debug("Applying S11 calibration (OSL)", sweep_number=sweep_data.sweep_number, points=sweep_data.total_points)
calibrated = self._apply_s11_osl(raw, calibration_set)
else: else:
raise ValueError(f"Unsupported measurement mode: {calibration_set.preset.mode}") raise ValueError(f"Unsupported measurement mode: {calibration_set.preset.mode}")
# Convert back to list of (real, imag) tuples return [(z.real, z.imag) for z in calibrated]
return [(complex_val.real, complex_val.imag) for complex_val in calibrated_array]
def _sweep_to_complex_array(self, sweep_data: SweepData) -> np.ndarray: # --------------------------------------------------------------------- #
"""Convert SweepData to complex numpy array.""" # Helpers
complex_data = [] # --------------------------------------------------------------------- #
for real, imag in sweep_data.points: @staticmethod
complex_data.append(complex(real, imag)) def _to_complex_array(sweep: SweepData) -> np.ndarray:
return np.array(complex_data) """Convert `SweepData.points` to a 1-D complex NumPy array."""
# Using vectorized construction for speed and clarity
if not sweep.points:
return np.empty(0, dtype=np.complex64)
arr = np.asarray(sweep.points, dtype=np.float32)
return arr[:, 0].astype(np.float32) + 1j * arr[:, 1].astype(np.float32)
def _apply_s21_calibration(self, raw_signal: np.ndarray, calibration_set: CalibrationSet) -> np.ndarray: @staticmethod
def _safe_divide(num: np.ndarray, den: np.ndarray, eps: float = 1e-12) -> np.ndarray:
""" """
Apply S21 (transmission) calibration using through standard. Elementwise complex-safe division with small epsilon guard.
Calibrated_S21 = Raw_Signal / Through_Reference Avoids division by zero by clamping |den|<eps to eps (preserves phase).
""" """
mask = np.abs(den) < eps
if np.any(mask):
den = den.copy()
# Scale only magnitude; keep angle of denominator
den[mask] = eps * np.exp(1j * np.angle(den[mask]))
return num / den
# Get through calibration data # --------------------------------------------------------------------- #
through_sweep = calibration_set.standards[CalibrationStandard.THROUGH] # S21 calibration (THRU)
through_reference = self._sweep_to_complex_array(through_sweep) # --------------------------------------------------------------------- #
def _apply_s21(self, raw: np.ndarray, calib: CalibrationSet) -> np.ndarray:
# 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. S21: normalize the DUT response by the THRU reference.
This implements the standard OSL error correction: Calibrated = Raw / Through
- 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))
""" """
through = self._to_complex_array(calib.standards[CalibrationStandard.THROUGH])
if raw.size != through.size:
raise ValueError("Signal and THRU reference have different lengths")
return self._safe_divide(raw, through)
# Get calibration standards # --------------------------------------------------------------------- #
open_sweep = calibration_set.standards[CalibrationStandard.OPEN] # S11 calibration (OSL)
short_sweep = calibration_set.standards[CalibrationStandard.SHORT] # --------------------------------------------------------------------- #
load_sweep = calibration_set.standards[CalibrationStandard.LOAD] def _apply_s11_osl(self, raw: np.ndarray, calib: CalibrationSet) -> np.ndarray:
"""
S11 OSL correction using a 3-term error model.
# Convert to complex arrays Error terms
open_cal = self._sweep_to_complex_array(open_sweep) -----------
short_cal = self._sweep_to_complex_array(short_sweep) Ed (directivity) := Load
load_cal = self._sweep_to_complex_array(load_sweep) Es (source match) := (Open + Short - 2*Load) / (Open - Short)
Er (reflection tracking) := -2*(Open - Load)*(Short - Load) / (Open - Short)
# Validate array sizes Final correction
if not (len(raw_signal) == len(open_cal) == len(short_cal) == len(load_cal)): ----------------
raise ValueError("Signal and calibration data have different lengths") S11 = (Raw - Ed) / (Er + Es * (Raw - Ed))
"""
open_ref = self._to_complex_array(calib.standards[CalibrationStandard.OPEN])
short_ref = self._to_complex_array(calib.standards[CalibrationStandard.SHORT])
load_ref = self._to_complex_array(calib.standards[CalibrationStandard.LOAD])
# Calculate error terms n = raw.size
directivity = load_cal.copy() # Ed = Load if not (open_ref.size == short_ref.size == load_ref.size == n):
raise ValueError("Signal and OSL standards have different lengths")
# Source match: Es = (Open + Short - 2*Load) / (Open - Short) # Ed
denominator = open_cal - short_cal ed = load_ref
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) # Es, Er with guarded denominators
reflection_tracking = -2 * (open_cal - load_cal) * (short_cal - load_cal) / denominator denom = open_ref - short_ref
denom = np.where(np.abs(denom) < 1e-12, 1e-12 * np.exp(1j * np.angle(denom)), denom)
# Apply OSL correction es = (open_ref + short_ref - 2.0 * load_ref) / denom
corrected_numerator = raw_signal - directivity er = -2.0 * (open_ref - load_ref) * (short_ref - load_ref) / denom
corrected_denominator = reflection_tracking + source_match * corrected_numerator
# Avoid division by zero num = raw - ed
corrected_denominator = np.where(np.abs(corrected_denominator) < 1e-12, 1e-12, corrected_denominator) den = er + es * num
return self._safe_divide(num, den)
calibrated_signal = corrected_numerator / corrected_denominator
return calibrated_signal

View File

@ -1,9 +1,10 @@
{ {
"y_min": -80, "y_min": -60,
"y_max": 15, "y_max": 20,
"smoothing_enabled": false, "smoothing_enabled": false,
"smoothing_window": 5, "smoothing_window": 5,
"marker_enabled": true, "marker_enabled": false,
"marker_frequency": 100000009, "marker_frequency": 100000009.0,
"grid_enabled": true "grid_enabled": false,
"reset_smoothing": false
} }

View File

@ -1,12 +1,12 @@
{ {
"y_min": -210, "y_min": -360,
"y_max": 360, "y_max": 360,
"unwrap_phase": false, "unwrap_phase": false,
"phase_offset": 0, "phase_offset": 0,
"smoothing_enabled": true, "smoothing_enabled": false,
"smoothing_window": 5, "smoothing_window": 5,
"marker_enabled": false, "marker_enabled": true,
"marker_frequency": "asdasd", "marker_frequency": 8000000000.0,
"reference_line_enabled": false, "reference_line_enabled": false,
"reference_phase": 0, "reference_phase": 0,
"grid_enabled": true "grid_enabled": true

View File

@ -1,221 +1,293 @@
import numpy as np import numpy as np
from typing import Dict, Any, List
from pathlib import Path from pathlib import Path
from typing import Any
from ..base_processor import BaseProcessor, UIParameter from vna_system.core.logging.logger import get_component_logger
from vna_system.core.processors.base_processor import BaseProcessor, UIParameter
logger = get_component_logger(__file__)
class MagnitudeProcessor(BaseProcessor): class MagnitudeProcessor(BaseProcessor):
def __init__(self, config_dir: Path): """
Compute and visualize magnitude (in dB) over frequency from calibrated sweep data.
Pipeline
--------
1) Derive frequency axis from VNA config (start/stop, N points).
2) Compute |S| in dB per point (20*log10(|complex|), clamped for |complex|==0).
3) Optionally smooth using moving-average (odd window).
4) Provide Plotly configuration including an optional marker.
Notes
-----
- `calibrated_data` is expected to be a `SweepData` with `.points: list[tuple[float,float]]`.
- Marker frequency is validated by BaseProcessor via `UIParameter(options=...)`.
"""
def __init__(self, config_dir: Path) -> None:
super().__init__("magnitude", config_dir) super().__init__("magnitude", config_dir)
# State for smoothing that can be reset by button # Internal state that can be reset via a UI "button"
self._smoothing_history = [] self._smoothing_history: list[float] = []
def process_sweep(self, sweep_data: Any, calibrated_data: Any, vna_config: Dict[str, Any]) -> Dict[str, Any]: # ------------------------------------------------------------------ #
if not calibrated_data or not hasattr(calibrated_data, 'points'): # Core processing
return {'error': 'No calibrated data available'} # ------------------------------------------------------------------ #
def process_sweep(self, sweep_data: Any, calibrated_data: Any, vna_config: dict[str, Any]) -> dict[str, Any]:
"""
Produce magnitude trace (dB) and ancillary info from a calibrated sweep.
frequencies = [] Returns
magnitudes_db = [] -------
dict[str, Any]
for i, (real, imag) in enumerate(calibrated_data.points): {
complex_val = complex(real, imag) 'frequencies': list[float],
magnitude_db = 20 * np.log10(abs(complex_val)) if abs(complex_val) > 0 else -120 'magnitudes_db': list[float],
'y_min': float,
# Calculate frequency based on VNA config 'y_max': float,
start_freq = vna_config.get('start_frequency', 100e6) 'marker_enabled': bool,
stop_freq = vna_config.get('stop_frequency', 8.8e9) 'marker_frequency': float,
total_points = len(calibrated_data.points) 'grid_enabled': bool
frequency = start_freq + (stop_freq - start_freq) * i / (total_points - 1)
frequencies.append(frequency)
magnitudes_db.append(magnitude_db)
# Apply smoothing if enabled
if self._config.get('smoothing_enabled', False):
window_size = self._config.get('smoothing_window', 5)
magnitudes_db = self._apply_moving_average(magnitudes_db, window_size)
return {
'frequencies': frequencies,
'magnitudes_db': magnitudes_db,
'y_min': self._config.get('y_min', -80),
'y_max': self._config.get('y_max', 10),
'marker_enabled': self._config.get('marker_enabled', True),
'marker_frequency': self._config.get('marker_frequency', frequencies[len(frequencies)//2] if frequencies else 1e9),
'grid_enabled': self._config.get('grid_enabled', True)
}
def generate_plotly_config(self, processed_data: Dict[str, Any], vna_config: Dict[str, Any]) -> Dict[str, Any]:
if 'error' in processed_data:
return {'error': processed_data['error']}
frequencies = processed_data['frequencies']
magnitudes_db = processed_data['magnitudes_db']
# Find marker point
marker_freq = processed_data['marker_frequency']
marker_idx = min(range(len(frequencies)), key=lambda i: abs(frequencies[i] - marker_freq))
marker_mag = magnitudes_db[marker_idx]
traces = [{
'x': [f / 1e9 for f in frequencies], # Convert to GHz
'y': magnitudes_db,
'type': 'scatter',
'mode': 'lines',
'name': 'S11 Magnitude',
'line': {'color': 'blue', 'width': 2}
}]
# Add marker if enabled
if processed_data['marker_enabled']:
traces.append({
'x': [frequencies[marker_idx] / 1e9],
'y': [marker_mag],
'type': 'scatter',
'mode': 'markers',
'name': f'Marker: {frequencies[marker_idx]/1e9:.3f} GHz, {marker_mag:.2f} dB',
'marker': {'color': 'red', 'size': 8, 'symbol': 'circle'}
})
return {
'data': traces,
'layout': {
'title': 'S11 Magnitude Response',
'xaxis': {
'title': 'Frequency (GHz)',
'showgrid': processed_data['grid_enabled']
},
'yaxis': {
'title': 'Magnitude (dB)',
'range': [processed_data['y_min'], processed_data['y_max']],
'showgrid': processed_data['grid_enabled']
},
'hovermode': 'x unified',
'showlegend': True
} }
} """
if not calibrated_data or not hasattr(calibrated_data, "points"):
logger.warning("No calibrated data available for magnitude processing")
return {"error": "No calibrated data available"}
def get_ui_parameters(self) -> List[UIParameter]: points: list[tuple[float, float]] = calibrated_data.points # list of (real, imag)
n = len(points)
if n == 0:
logger.warning("Calibrated sweep contains zero points")
return {"error": "Empty calibrated sweep"}
# Frequency axis from VNA config (defaults if not provided)
start_freq = float(vna_config.get("start_frequency", 100e6))
stop_freq = float(vna_config.get("stop_frequency", 8.8e9))
if n == 1:
freqs = [start_freq]
else:
step = (stop_freq - start_freq) / (n - 1)
freqs = [start_freq + i * step for i in range(n)]
# Magnitude in dB (clamp zero magnitude to -120 dB)
mags_db: list[float] = []
for real, imag in points:
mag = abs(complex(real, imag))
mags_db.append(20.0 * np.log10(mag) if mag > 0.0 else -120.0)
# Optional smoothing
if self._config.get("smoothing_enabled", False):
window = int(self._config.get("smoothing_window", 5))
mags_db = self._apply_moving_average(mags_db, window)
result = {
"frequencies": freqs,
"magnitudes_db": mags_db,
"y_min": float(self._config.get("y_min", -80)),
"y_max": float(self._config.get("y_max", 10)),
"marker_enabled": bool(self._config.get("marker_enabled", True)),
"marker_frequency": float(
self._config.get("marker_frequency", freqs[len(freqs) // 2] if freqs else 1e9)
),
"grid_enabled": bool(self._config.get("grid_enabled", True)),
}
logger.debug("Magnitude sweep processed", points=n)
return result
def generate_plotly_config(self, processed_data: dict[str, Any], vna_config: dict[str, Any]) -> dict[str, Any]:
"""
Build a Plotly figure config for the magnitude trace and optional marker.
"""
if "error" in processed_data:
return {"error": processed_data["error"]}
freqs: list[float] = processed_data["frequencies"]
mags_db: list[float] = processed_data["magnitudes_db"]
grid_enabled: bool = processed_data["grid_enabled"]
# Marker resolution
marker_freq: float = processed_data["marker_frequency"]
if freqs:
idx = min(range(len(freqs)), key=lambda i: abs(freqs[i] - marker_freq))
marker_mag = mags_db[idx]
marker_x = freqs[idx] / 1e9
marker_trace = {
"x": [marker_x],
"y": [marker_mag],
"type": "scatter",
"mode": "markers",
"name": f"Marker: {freqs[idx]/1e9:.3f} GHz, {marker_mag:.2f} dB",
"marker": {"color": "red", "size": 8, "symbol": "circle"},
}
else:
idx = 0
marker_trace = None
traces = [
{
"x": [f / 1e9 for f in freqs], # Hz -> GHz
"y": mags_db,
"type": "scatter",
"mode": "lines",
"name": "Magnitude",
"line": {"color": "blue", "width": 2},
}
]
if processed_data["marker_enabled"] and marker_trace:
traces.append(marker_trace)
fig = {
"data": traces,
"layout": {
"title": "Magnitude Response",
"xaxis": {"title": "Frequency (GHz)", "showgrid": grid_enabled},
"yaxis": {
"title": "Magnitude (dB)",
"range": [processed_data["y_min"], processed_data["y_max"]],
"showgrid": grid_enabled,
},
"hovermode": "x unified",
"showlegend": True,
},
}
return fig
# ------------------------------------------------------------------ #
# UI schema
# ------------------------------------------------------------------ #
def get_ui_parameters(self) -> list[UIParameter]:
"""
UI/validation schema.
Conforms to BaseProcessor rules:
- slider: requires dtype + min/max/step alignment checks
- toggle: bool only
- input: numeric only, with {"type": "int"|"float", "min"?, "max"?}
- button: {"action": "..."}; value ignored by validation
"""
return [ return [
UIParameter( UIParameter(
name='y_min', name="y_min",
label='Y Axis Min (dB)', label="Y Axis Min (dB)",
type='slider', type="slider",
value=self._config.get('y_min', -80), value=self._config.get("y_min", -80),
options={'min': -120, 'max': 0, 'step': 5} options={"min": -120, "max": 0, "step": 5, "dtype": "int"},
), ),
UIParameter( UIParameter(
name='y_max', name="y_max",
label='Y Axis Max (dB)', label="Y Axis Max (dB)",
type='slider', type="slider",
value=self._config.get('y_max', 10), value=self._config.get("y_max", 10),
options={'min': -20, 'max': 20, 'step': 5} options={"min": -20, "max": 20, "step": 5, "dtype": "int"},
), ),
UIParameter( UIParameter(
name='smoothing_enabled', name="smoothing_enabled",
label='Enable Smoothing', label="Enable Smoothing",
type='toggle', type="toggle",
value=self._config.get('smoothing_enabled', False) value=self._config.get("smoothing_enabled", False),
options={},
), ),
UIParameter( UIParameter(
name='smoothing_window', name="smoothing_window",
label='Smoothing Window Size', label="Smoothing Window Size",
type='slider', type="slider",
value=self._config.get('smoothing_window', 5), value=self._config.get("smoothing_window", 5),
options={'min': 3, 'max': 21, 'step': 2} options={"min": 3, "max": 21, "step": 2, "dtype": "int"},
), ),
UIParameter( UIParameter(
name='marker_enabled', name="marker_enabled",
label='Show Marker', label="Show Marker",
type='toggle', type="toggle",
value=self._config.get('marker_enabled', True) value=self._config.get("marker_enabled", True),
options={},
), ),
UIParameter( UIParameter(
name='marker_frequency', name="marker_frequency",
label='Marker Frequency (Hz)', label="Marker Frequency (Hz)",
type='input', type="input",
value=self._config.get('marker_frequency', 1e9), value=self._config.get("marker_frequency", 1e9),
options={'type': 'number', 'min': 100e6, 'max': 8.8e9} options={"type": "float", "min": 100e6, "max": 8.8e9},
), ),
UIParameter( UIParameter(
name='grid_enabled', name="grid_enabled",
label='Show Grid', label="Show Grid",
type='toggle', type="toggle",
value=self._config.get('grid_enabled', True) value=self._config.get("grid_enabled", True),
options={},
), ),
UIParameter( UIParameter(
name='reset_smoothing', name="reset_smoothing",
label='Reset Smoothing', label="Reset Smoothing",
type='button', type="button",
value=False, # Always False for buttons, will be set to True temporarily when clicked value=False, # buttons carry no state; ignored by validator
options={'action': 'Reset the smoothing filter state'} options={"action": "Reset the smoothing filter state"},
) ),
] ]
def _get_default_config(self) -> Dict[str, Any]: def _get_default_config(self) -> dict[str, Any]:
"""Defaults that align with the UI schema above."""
return { return {
'y_min': -80, "y_min": -80,
'y_max': 10, "y_max": 10,
'smoothing_enabled': False, "smoothing_enabled": False,
'smoothing_window': 5, "smoothing_window": 5,
'marker_enabled': True, "marker_enabled": True,
'marker_frequency': 1e9, "marker_frequency": 1e9,
'grid_enabled': True "grid_enabled": True,
} }
def update_config(self, updates: Dict[str, Any]): # ------------------------------------------------------------------ #
print(f"🔧 update_config called with: {updates}") # Config updates & actions
# Handle button parameters specially # ------------------------------------------------------------------ #
button_actions = {} def update_config(self, updates: dict[str, Any]) -> None:
config_updates = {} """
Apply config updates; handle UI buttons out-of-band.
Any key that corresponds to a button triggers an action when True and
is *not* persisted in the config. Other keys are forwarded to the
BaseProcessor (with type conversion + validation).
"""
ui_params = {param.name: param for param in self.get_ui_parameters()}
button_actions: dict[str, bool] = {}
config_updates: dict[str, Any] = {}
for key, value in updates.items(): for key, value in updates.items():
# Check if this is a button parameter schema = ui_params.get(key)
ui_params = {param.name: param for param in self.get_ui_parameters()} if schema and schema.type == "button":
if key in ui_params and ui_params[key].type == 'button': if value:
if value: # Button was clicked button_actions[key] = True
button_actions[key] = value
# Don't add button values to config
else: else:
config_updates[key] = value config_updates[key] = value
# Update config with non-button parameters
if config_updates: if config_updates:
super().update_config(config_updates) super().update_config(config_updates)
# Handle button actions # Execute button actions
for action, pressed in button_actions.items(): if button_actions.get("reset_smoothing"):
if pressed and action == 'reset_smoothing': self._smoothing_history.clear()
# Reset smoothing state (could be a counter, filter state, etc.) logger.info("Smoothing state reset via UI button")
self._smoothing_history = [] # Reset any internal smoothing state
print(f"🔄 Smoothing state reset by button action")
# Note: recalculate() will be called automatically by the processing system
def _validate_config(self): # ------------------------------------------------------------------ #
required_keys = ['y_min', 'y_max', 'smoothing_enabled', 'smoothing_window', # Smoothing
'marker_enabled', 'marker_frequency', 'grid_enabled'] # ------------------------------------------------------------------ #
@staticmethod
def _apply_moving_average(data: list[float], window_size: int) -> list[float]:
"""
Centered moving average with clamped edges.
for key in required_keys: Requirements
if key not in self._config: ------------
raise ValueError(f"Missing required config key: {key}") - window_size must be odd and >= 3 (enforced by UI schema).
"""
if self._config['y_min'] >= self._config['y_max']: n = len(data)
raise ValueError("y_min must be less than y_max") if n == 0 or window_size <= 1 or window_size >= n:
if self._config['smoothing_window'] < 3 or self._config['smoothing_window'] % 2 == 0:
raise ValueError("smoothing_window must be odd and >= 3")
def _apply_moving_average(self, data: List[float], window_size: int) -> List[float]:
if window_size >= len(data):
return data return data
smoothed = [] half = window_size // 2
half_window = window_size // 2 out: list[float] = []
for i in range(n):
for i in range(len(data)): lo = max(0, i - half)
start_idx = max(0, i - half_window) hi = min(n, i + half + 1)
end_idx = min(len(data), i + half_window + 1) out.append(sum(data[lo:hi]) / (hi - lo))
smoothed.append(sum(data[start_idx:end_idx]) / (end_idx - start_idx)) return out
return smoothed

View File

@ -1,242 +1,307 @@
import numpy as np import numpy as np
from typing import Dict, Any, List
from pathlib import Path from pathlib import Path
from typing import Any
from ..base_processor import BaseProcessor, UIParameter from vna_system.core.logging.logger import get_component_logger
from vna_system.core.processors.base_processor import BaseProcessor, UIParameter
logger = get_component_logger(__file__)
class PhaseProcessor(BaseProcessor): class PhaseProcessor(BaseProcessor):
def __init__(self, config_dir: Path): """
Compute and visualize phase (degrees) over frequency from calibrated sweep data.
Pipeline
--------
1) Derive frequency axis from VNA config (start/stop, N points).
2) Compute phase in degrees from complex samples.
3) Optional simple unwrapping (+/-180° jumps) and offset.
4) Optional moving-average smoothing.
5) Provide Plotly configuration including an optional marker and reference line.
"""
def __init__(self, config_dir: Path) -> None:
super().__init__("phase", config_dir) super().__init__("phase", config_dir)
def process_sweep(self, sweep_data: Any, calibrated_data: Any, vna_config: Dict[str, Any]) -> Dict[str, Any]: # ------------------------------------------------------------------ #
if not calibrated_data or not hasattr(calibrated_data, 'points'): # Core processing
return {'error': 'No calibrated data available'} # ------------------------------------------------------------------ #
def process_sweep(self, sweep_data: Any, calibrated_data: Any, vna_config: dict[str, Any]) -> dict[str, Any]:
"""
Produce phase trace (degrees) and ancillary info from a calibrated sweep.
frequencies = [] Returns
phases_deg = [] -------
dict[str, Any]
for i, (real, imag) in enumerate(calibrated_data.points): {
complex_val = complex(real, imag) 'frequencies': list[float],
phase_rad = np.angle(complex_val) 'phases_deg': list[float],
phase_deg = np.degrees(phase_rad) 'y_min': float,
'y_max': float,
# Phase unwrapping if enabled 'marker_enabled': bool,
if self._config.get('unwrap_phase', True) and i > 0: 'marker_frequency': float,
phase_diff = phase_deg - phases_deg[-1] 'grid_enabled': bool,
if phase_diff > 180: 'reference_line_enabled': bool,
phase_deg -= 360 'reference_phase': float
elif phase_diff < -180:
phase_deg += 360
# Calculate frequency
start_freq = vna_config.get('start_frequency', 100e6)
stop_freq = vna_config.get('stop_frequency', 8.8e9)
total_points = len(calibrated_data.points)
frequency = start_freq + (stop_freq - start_freq) * i / (total_points - 1)
frequencies.append(frequency)
phases_deg.append(phase_deg)
# Apply offset if configured
phase_offset = self._config.get('phase_offset', 0)
if phase_offset != 0:
phases_deg = [phase + phase_offset for phase in phases_deg]
# Apply smoothing if enabled
if self._config.get('smoothing_enabled', False):
window_size = self._config.get('smoothing_window', 5)
phases_deg = self._apply_moving_average(phases_deg, window_size)
return {
'frequencies': frequencies,
'phases_deg': phases_deg,
'y_min': self._config.get('y_min', -180),
'y_max': self._config.get('y_max', 180),
'marker_enabled': self._config.get('marker_enabled', True),
'marker_frequency': self._config.get('marker_frequency', frequencies[len(frequencies)//2] if frequencies else 1e9),
'grid_enabled': self._config.get('grid_enabled', True),
'reference_line_enabled': self._config.get('reference_line_enabled', False),
'reference_phase': self._config.get('reference_phase', 0)
}
def generate_plotly_config(self, processed_data: Dict[str, Any], vna_config: Dict[str, Any]) -> Dict[str, Any]:
if 'error' in processed_data:
return {'error': processed_data['error']}
frequencies = processed_data['frequencies']
phases_deg = processed_data['phases_deg']
# Find marker point
marker_freq = processed_data['marker_frequency']
marker_idx = min(range(len(frequencies)), key=lambda i: abs(frequencies[i] - marker_freq))
marker_phase = phases_deg[marker_idx]
traces = [{
'x': [f / 1e9 for f in frequencies], # Convert to GHz
'y': phases_deg,
'type': 'scatter',
'mode': 'lines',
'name': 'S11 Phase',
'line': {'color': 'green', 'width': 2}
}]
# Add marker if enabled
if processed_data['marker_enabled']:
traces.append({
'x': [frequencies[marker_idx] / 1e9],
'y': [marker_phase],
'type': 'scatter',
'mode': 'markers',
'name': f'Marker: {frequencies[marker_idx]/1e9:.3f} GHz, {marker_phase:.1f}°',
'marker': {'color': 'red', 'size': 8, 'symbol': 'circle'}
})
# Add reference line if enabled
if processed_data['reference_line_enabled']:
traces.append({
'x': [frequencies[0] / 1e9, frequencies[-1] / 1e9],
'y': [processed_data['reference_phase'], processed_data['reference_phase']],
'type': 'scatter',
'mode': 'lines',
'name': f'Reference: {processed_data["reference_phase"]:.1f}°',
'line': {'color': 'gray', 'width': 1, 'dash': 'dash'}
})
return {
'data': traces,
'layout': {
'title': 'S11 Phase Response',
'xaxis': {
'title': 'Frequency (GHz)',
'showgrid': processed_data['grid_enabled']
},
'yaxis': {
'title': 'Phase (degrees)',
'range': [processed_data['y_min'], processed_data['y_max']],
'showgrid': processed_data['grid_enabled']
},
'hovermode': 'x unified',
'showlegend': True
} }
} """
if not calibrated_data or not hasattr(calibrated_data, "points"):
logger.warning("No calibrated data available for phase processing")
return {"error": "No calibrated data available"}
def get_ui_parameters(self) -> List[UIParameter]: points: list[tuple[float, float]] = calibrated_data.points
n = len(points)
if n == 0:
logger.warning("Calibrated sweep contains zero points")
return {"error": "Empty calibrated sweep"}
# Frequency axis from VNA config (defaults if not provided)
start_freq = float(vna_config.get("start_frequency", 100e6))
stop_freq = float(vna_config.get("stop_frequency", 8.8e9))
if n == 1:
freqs = [start_freq]
else:
step = (stop_freq - start_freq) / (n - 1)
freqs = [start_freq + i * step for i in range(n)]
# Phase in degrees
phases_deg: list[float] = []
unwrap = bool(self._config.get("unwrap_phase", True))
for i, (real, imag) in enumerate(points):
z = complex(real, imag)
deg = float(np.degrees(np.angle(z)))
if unwrap and phases_deg:
diff = deg - phases_deg[-1]
if diff > 180.0:
deg -= 360.0
elif diff < -180.0:
deg += 360.0
phases_deg.append(deg)
# Offset
phase_offset = float(self._config.get("phase_offset", 0.0))
if phase_offset:
phases_deg = [p + phase_offset for p in phases_deg]
# Optional smoothing
if self._config.get("smoothing_enabled", False):
window = int(self._config.get("smoothing_window", 5))
phases_deg = self._apply_moving_average(phases_deg, window)
result = {
"frequencies": freqs,
"phases_deg": phases_deg,
"y_min": float(self._config.get("y_min", -180)),
"y_max": float(self._config.get("y_max", 180)),
"marker_enabled": bool(self._config.get("marker_enabled", True)),
"marker_frequency": float(
self._config.get("marker_frequency", freqs[len(freqs) // 2] if freqs else 1e9)
),
"grid_enabled": bool(self._config.get("grid_enabled", True)),
"reference_line_enabled": bool(self._config.get("reference_line_enabled", False)),
"reference_phase": float(self._config.get("reference_phase", 0)),
}
logger.debug("Phase sweep processed", points=n)
return result
def generate_plotly_config(self, processed_data: dict[str, Any], vna_config: dict[str, Any]) -> dict[str, Any]:
"""
Build a Plotly figure config for the phase trace, marker, and optional reference line.
"""
if "error" in processed_data:
return {"error": processed_data["error"]}
freqs: list[float] = processed_data["frequencies"]
phases: list[float] = processed_data["phases_deg"]
grid_enabled: bool = processed_data["grid_enabled"]
# Marker
marker_freq: float = processed_data["marker_frequency"]
if freqs:
idx = min(range(len(freqs)), key=lambda i: abs(freqs[i] - marker_freq))
marker_y = phases[idx]
marker_trace = {
"x": [freqs[idx] / 1e9],
"y": [marker_y],
"type": "scatter",
"mode": "markers",
"name": f"Marker: {freqs[idx]/1e9:.3f} GHz, {marker_y:.1f}°",
"marker": {"color": "red", "size": 8, "symbol": "circle"},
}
else:
marker_trace = None
traces = [
{
"x": [f / 1e9 for f in freqs], # Hz -> GHz
"y": phases,
"type": "scatter",
"mode": "lines",
"name": "Phase",
"line": {"color": "green", "width": 2},
}
]
if processed_data["marker_enabled"] and marker_trace:
traces.append(marker_trace)
# Reference line
if processed_data["reference_line_enabled"] and freqs:
ref = float(processed_data["reference_phase"])
traces.append(
{
"x": [freqs[0] / 1e9, freqs[-1] / 1e9],
"y": [ref, ref],
"type": "scatter",
"mode": "lines",
"name": f"Reference: {ref:.1f}°",
"line": {"color": "gray", "width": 1, "dash": "dash"},
}
)
fig = {
"data": traces,
"layout": {
"title": "Phase Response",
"xaxis": {"title": "Frequency (GHz)", "showgrid": grid_enabled},
"yaxis": {
"title": "Phase (degrees)",
"range": [processed_data["y_min"], processed_data["y_max"]],
"showgrid": grid_enabled,
},
"hovermode": "x unified",
"showlegend": True,
},
}
return fig
# ------------------------------------------------------------------ #
# UI schema
# ------------------------------------------------------------------ #
def get_ui_parameters(self) -> list[UIParameter]:
"""
UI/validation schema (compatible with BaseProcessor's validators).
"""
return [ return [
UIParameter( UIParameter(
name='y_min', name="y_min",
label='Y Axis Min (degrees)', label="Y Axis Min (degrees)",
type='slider', type="slider",
value=self._config.get('y_min', -180), value=self._config.get("y_min", -180),
options={'min': -360, 'max': 0, 'step': 15} options={"min": -360, "max": 0, "step": 15, "dtype": "int"},
), ),
UIParameter( UIParameter(
name='y_max', name="y_max",
label='Y Axis Max (degrees)', label="Y Axis Max (degrees)",
type='slider', type="slider",
value=self._config.get('y_max', 180), value=self._config.get("y_max", 180),
options={'min': 0, 'max': 360, 'step': 15} options={"min": 0, "max": 360, "step": 15, "dtype": "int"},
), ),
UIParameter( UIParameter(
name='unwrap_phase', name="unwrap_phase",
label='Unwrap Phase', label="Unwrap Phase",
type='toggle', type="toggle",
value=self._config.get('unwrap_phase', True) value=self._config.get("unwrap_phase", True),
options={},
), ),
UIParameter( UIParameter(
name='phase_offset', name="phase_offset",
label='Phase Offset (degrees)', label="Phase Offset (degrees)",
type='slider', type="slider",
value=self._config.get('phase_offset', 0), value=self._config.get("phase_offset", 0),
options={'min': -180, 'max': 180, 'step': 5} options={"min": -180, "max": 180, "step": 5, "dtype": "int"},
), ),
UIParameter( UIParameter(
name='smoothing_enabled', name="smoothing_enabled",
label='Enable Smoothing', label="Enable Smoothing",
type='toggle', type="toggle",
value=self._config.get('smoothing_enabled', False) value=self._config.get("smoothing_enabled", False),
options={},
), ),
UIParameter( UIParameter(
name='smoothing_window', name="smoothing_window",
label='Smoothing Window Size', label="Smoothing Window Size",
type='slider', type="slider",
value=self._config.get('smoothing_window', 5), value=self._config.get("smoothing_window", 5),
options={'min': 3, 'max': 21, 'step': 2} options={"min": 3, "max": 21, "step": 2, "dtype": "int"},
), ),
UIParameter( UIParameter(
name='marker_enabled', name="marker_enabled",
label='Show Marker', label="Show Marker",
type='toggle', type="toggle",
value=self._config.get('marker_enabled', True) value=self._config.get("marker_enabled", True),
options={},
), ),
UIParameter( UIParameter(
name='marker_frequency', name="marker_frequency",
label='Marker Frequency (Hz)', label="Marker Frequency (Hz)",
type='input', type="input",
value=self._config.get('marker_frequency', 1e9), value=self._config.get("marker_frequency", 1e9),
options={'type': 'number', 'min': 100e6, 'max': 8.8e9} options={"type": "float", "min": 100e6, "max": 8.8e9},
), ),
UIParameter( UIParameter(
name='reference_line_enabled', name="reference_line_enabled",
label='Show Reference Line', label="Show Reference Line",
type='toggle', type="toggle",
value=self._config.get('reference_line_enabled', False) value=self._config.get("reference_line_enabled", False),
options={},
), ),
UIParameter( UIParameter(
name='reference_phase', name="reference_phase",
label='Reference Phase (degrees)', label="Reference Phase (degrees)",
type='slider', type="slider",
value=self._config.get('reference_phase', 0), value=self._config.get("reference_phase", 0),
options={'min': -180, 'max': 180, 'step': 15} options={"min": -180, "max": 180, "step": 15, "dtype": "int"},
), ),
UIParameter( UIParameter(
name='grid_enabled', name="grid_enabled",
label='Show Grid', label="Show Grid",
type='toggle', type="toggle",
value=self._config.get('grid_enabled', True) value=self._config.get("grid_enabled", True),
) options={},
),
] ]
def _get_default_config(self) -> Dict[str, Any]: def _get_default_config(self) -> dict[str, Any]:
"""Defaults aligned with the UI schema."""
return { return {
'y_min': -180, "y_min": -180,
'y_max': 180, "y_max": 180,
'unwrap_phase': True, "unwrap_phase": True,
'phase_offset': 0, "phase_offset": 0,
'smoothing_enabled': False, "smoothing_enabled": False,
'smoothing_window': 5, "smoothing_window": 5,
'marker_enabled': True, "marker_enabled": True,
'marker_frequency': 1e9, "marker_frequency": 1e9,
'reference_line_enabled': False, "reference_line_enabled": False,
'reference_phase': 0, "reference_phase": 0,
'grid_enabled': True "grid_enabled": True,
} }
def _validate_config(self): # ------------------------------------------------------------------ #
required_keys = ['y_min', 'y_max', 'unwrap_phase', 'phase_offset', # Smoothing
'smoothing_enabled', 'smoothing_window', 'marker_enabled', # ------------------------------------------------------------------ #
'marker_frequency', 'reference_line_enabled', 'reference_phase', 'grid_enabled'] @staticmethod
def _apply_moving_average(data: list[float], window_size: int) -> list[float]:
"""
Centered moving average with clamped edges.
for key in required_keys: Requirements
if key not in self._config: ------------
raise ValueError(f"Missing required config key: {key}") - `window_size` must be odd and >= 3 (enforced by UI schema).
"""
if self._config['y_min'] >= self._config['y_max']: n = len(data)
raise ValueError("y_min must be less than y_max") if n == 0 or window_size <= 1 or window_size >= n:
if self._config['smoothing_window'] < 3 or self._config['smoothing_window'] % 2 == 0:
raise ValueError("smoothing_window must be odd and >= 3")
def _apply_moving_average(self, data: List[float], window_size: int) -> List[float]:
if window_size >= len(data):
return data return data
smoothed = [] half = window_size // 2
half_window = window_size // 2 out: list[float] = []
for i in range(n):
for i in range(len(data)): lo = max(0, i - half)
start_idx = max(0, i - half_window) hi = min(n, i + half + 1)
end_idx = min(len(data), i + half_window + 1) out.append(sum(data[lo:hi]) / (hi - lo))
smoothed.append(sum(data[start_idx:end_idx]) / (end_idx - start_idx)) return out
return smoothed

View File

@ -1,74 +1,139 @@
from typing import Dict, List, Optional, Any, Callable
import threading
import logging
from pathlib import Path from pathlib import Path
from typing import Any, Callable
from vna_system.core.settings.preset_manager import ConfigPreset import threading
from vna_system.core.logging.logger import get_component_logger
from vna_system.core.processors.base_processor import BaseProcessor, ProcessedResult from vna_system.core.processors.base_processor import BaseProcessor, ProcessedResult
from vna_system.core.processors.calibration_processor import CalibrationProcessor from vna_system.core.processors.calibration_processor import CalibrationProcessor
from vna_system.core.acquisition.sweep_buffer import SweepBuffer, SweepData from vna_system.core.acquisition.sweep_buffer import SweepBuffer, SweepData
from vna_system.core.settings.preset_manager import ConfigPreset
from vna_system.core.settings.settings_manager import VNASettingsManager from vna_system.core.settings.settings_manager import VNASettingsManager
logger = get_component_logger(__file__)
class ProcessorManager: class ProcessorManager:
def __init__(self, sweep_buffer: SweepBuffer, settings_manager: VNASettingsManager, config_dir: Path): """
self.config_dir = config_dir Orchestrates VNA processors and pushes sweeps through them in the background.
self._processors: Dict[str, BaseProcessor] = {}
self._lock = threading.RLock()
self._result_callbacks: List[Callable[[str, ProcessedResult], None]] = []
self.logger = logging.getLogger(__name__)
# Data acquisition integration Responsibilities
self.sweep_buffer: SweepBuffer = sweep_buffer ----------------
• Keep a registry of `BaseProcessor` instances (add/get/list).
• Subscribe result callbacks that receive every `ProcessedResult`.
• Watch the `SweepBuffer` and, when a new sweep arrives:
1) Optionally apply calibration.
2) Fetch current VNA preset/config.
3) Feed the sweep to every registered processor.
4) Fan-out results to callbacks.
• Offer on-demand (re)calculation for a specific processor (with config updates).
Threading model
---------------
A single background thread (`_processing_loop`) polls the `SweepBuffer` for the
newest sweep. Access to internal state (processors, callbacks) is guarded by
`_lock` when mutation is possible.
"""
def __init__(self, sweep_buffer: SweepBuffer, settings_manager: VNASettingsManager, config_dir: Path) -> None:
# External deps
self.sweep_buffer = sweep_buffer
self.settings_manager = settings_manager
self.config_dir = config_dir
# Registry & fan-out
self._processors: dict[str, BaseProcessor] = {}
self._result_callbacks: list[Callable[[str, ProcessedResult], None]] = []
# Concurrency
self._lock = threading.RLock()
self._running = False self._running = False
self._thread: Optional[threading.Thread] = None self._thread: threading.Thread | None = None
self._stop_event = threading.Event() self._stop_event = threading.Event()
# Sweep progress
self._last_processed_sweep = 0 self._last_processed_sweep = 0
# Calibration processor for applying calibrations # Calibration facility
self.calibration_processor = CalibrationProcessor() self.calibration_processor = CalibrationProcessor()
self.settings_manager = settings_manager # Default processors (safe to skip if missing)
# Register default processors
self._register_default_processors() self._register_default_processors()
logger.debug(
"ProcessorManager initialized",
processors=list(self._processors.keys()),
config_dir=str(self.config_dir),
)
def register_processor(self, processor: BaseProcessor): # --------------------------------------------------------------------- #
# Registry
# --------------------------------------------------------------------- #
def register_processor(self, processor: BaseProcessor) -> None:
"""Register (or replace) a processor by its `processor_id`."""
with self._lock: with self._lock:
self._processors[processor.processor_id] = processor self._processors[processor.processor_id] = processor
self.logger.info(f"Registered processor: {processor.processor_id}") logger.info("Processor registered", processor_id=processor.processor_id)
def get_processor(self, processor_id: str) -> Optional[BaseProcessor]: def get_processor(self, processor_id: str) -> BaseProcessor | None:
"""Return a processor instance by id, or None if not found."""
return self._processors.get(processor_id) return self._processors.get(processor_id)
def list_processors(self) -> List[str]: def list_processors(self) -> list[str]:
"""Return a stable snapshot list of registered processor ids."""
return list(self._processors.keys()) return list(self._processors.keys())
def add_result_callback(self, callback: Callable[[str, ProcessedResult], None]): # --------------------------------------------------------------------- #
print("adding callback") # Results fan-out
self._result_callbacks.append(callback) # --------------------------------------------------------------------- #
def add_result_callback(self, callback: Callable[[str, ProcessedResult], None]) -> None:
"""
Add a callback invoked for every produced result.
def process_sweep(self, sweep_data: SweepData, calibrated_data: Any, vna_config: ConfigPreset | None): Callback signature:
results = {} (processor_id: str, result: ProcessedResult) -> None
print(f"Processing sweep {sweep_data.sweep_number=}") """
with self._lock: with self._lock:
for processor_id, processor in self._processors.items(): self._result_callbacks.append(callback)
try: logger.debug("Result callback added", callbacks=len(self._result_callbacks))
result = processor.add_sweep_data(sweep_data, calibrated_data, vna_config)
if result:
results[processor_id] = result
for callback in self._result_callbacks:
try:
callback(processor_id, result)
except Exception as e:
self.logger.error(f"Callback error for {processor_id}: {e}")
except Exception as e: # --------------------------------------------------------------------- #
self.logger.error(f"Processing error in {processor_id}: {e}") # Main processing actions
# --------------------------------------------------------------------- #
def process_sweep(self, sweep_data: SweepData, calibrated_data: Any, vna_config: ConfigPreset | None) -> dict[str, ProcessedResult]:
"""
Feed a sweep into all processors and dispatch results to callbacks.
Returns a map {processor_id: ProcessedResult} for successfully computed processors.
"""
results: dict[str, ProcessedResult] = {}
with self._lock:
# Snapshot to avoid holding the lock while user callbacks run
processors_items = list(self._processors.items())
callbacks = list(self._result_callbacks)
for processor_id, processor in processors_items:
try:
result = processor.add_sweep_data(sweep_data, calibrated_data, vna_config)
if result:
results[processor_id] = result
for cb in callbacks:
try:
cb(processor_id, result)
except Exception as exc: # noqa: BLE001
logger.error("Result callback failed", processor_id=processor_id, error=repr(exc))
except Exception as exc: # noqa: BLE001
logger.error("Processing error", processor_id=processor_id, error=repr(exc))
return results return results
def recalculate_processor(self, processor_id: str, config_updates: Optional[Dict[str, Any]] = None) -> Optional[ProcessedResult]: def recalculate_processor(self, processor_id: str, config_updates: dict[str, Any] | None = None) -> ProcessedResult | None:
"""
Recalculate a single processor with optional config updates.
- If `config_updates` is provided, they are applied and validated first.
- The latest sweep (from the processor's own history) is used for recomputation.
- Result callbacks are invoked if a result is produced.
"""
processor = self.get_processor(processor_id) processor = self.get_processor(processor_id)
if not processor: if not processor:
raise ValueError(f"Processor {processor_id} not found") raise ValueError(f"Processor {processor_id} not found")
@ -79,111 +144,134 @@ class ProcessorManager:
result = processor.recalculate() result = processor.recalculate()
if result: if result:
for callback in self._result_callbacks: with self._lock:
callbacks = list(self._result_callbacks)
for cb in callbacks:
try: try:
callback(processor_id, result) cb(processor_id, result)
except Exception as e: except Exception as exc: # noqa: BLE001
self.logger.error(f"Callback error for {processor_id}: {e}") logger.error("Result callback failed", processor_id=processor_id, error=repr(exc))
return result return result
except Exception as exc: # noqa: BLE001
except Exception as e: logger.error("Recalculation error", processor_id=processor_id, error=repr(exc))
self.logger.error(f"Recalculation error in {processor_id}: {e}")
raise raise
# --------------------------------------------------------------------- #
def get_processor_ui_parameters(self, processor_id: str): # Runtime control
processor = self.get_processor(processor_id) # --------------------------------------------------------------------- #
if not processor: def set_sweep_buffer(self, sweep_buffer: SweepBuffer) -> None:
raise ValueError(f"Processor {processor_id} not found") """Swap the underlying sweep buffer (takes effect immediately)."""
return [param.__dict__ for param in processor.get_ui_parameters()]
def _register_default_processors(self):
"""Register default processors"""
try:
from .implementations import MagnitudeProcessor, PhaseProcessor, SmithChartProcessor
magnitude_processor = MagnitudeProcessor(self.config_dir)
self.register_processor(magnitude_processor)
phase_processor = PhaseProcessor(self.config_dir)
self.register_processor(phase_processor)
smith_processor = SmithChartProcessor(self.config_dir)
self.register_processor(smith_processor)
self.logger.info("Default processors registered successfully")
except Exception as e:
self.logger.error(f"Failed to register default processors: {e}")
def set_sweep_buffer(self, sweep_buffer: SweepBuffer):
"""Set the sweep buffer for data acquisition integration"""
self.sweep_buffer = sweep_buffer self.sweep_buffer = sweep_buffer
logger.info("Sweep buffer updated")
def start_processing(self): def start_processing(self) -> None:
"""Start background processing of sweep data""" """
if self._running or not self.sweep_buffer: Start the background processing thread.
Safe to call multiple times (no-op if already running).
"""
if self._running:
logger.debug("start_processing ignored; already running")
return return
self._running = True self._running = True
self._stop_event.clear() self._stop_event.clear()
self._thread = threading.Thread(target=self._processing_loop, daemon=True) self._thread = threading.Thread(target=self._processing_loop, daemon=True, name="VNA-ProcessorManager")
self._thread.start() self._thread.start()
self.logger.info("Processor manager started") logger.info("Processor manager started")
def stop_processing(self): def stop_processing(self) -> None:
"""Stop background processing""" """
Stop the background thread and wait briefly for it to join.
"""
if not self._running: if not self._running:
logger.debug("stop_processing ignored; not running")
return return
self._running = False self._running = False
self._stop_event.set() self._stop_event.set()
if self._thread: if self._thread:
self._thread.join(timeout=1.0) self._thread.join(timeout=1.0)
self.logger.info("Processor manager stopped") logger.info("Processor manager stopped")
def _processing_loop(self): # --------------------------------------------------------------------- #
"""Main processing loop""" # Background loop
# --------------------------------------------------------------------- #
def _processing_loop(self) -> None:
"""
Poll the sweep buffer and process any new sweep.
The loop:
• Grabs the latest sweep.
• Skips if it's already processed.
• Applies calibration (when available).
• Retrieves current VNA preset.
• Sends the sweep to all processors.
• Sleeps briefly to keep CPU usage low.
"""
while self._running and not self._stop_event.is_set(): while self._running and not self._stop_event.is_set():
try: try:
latest_sweep = self.sweep_buffer.get_latest_sweep() latest = self.sweep_buffer.get_latest_sweep()
if latest and latest.sweep_number > self._last_processed_sweep:
calibrated = self._apply_calibration(latest)
vna_cfg = self.settings_manager.get_current_preset()
self.process_sweep(latest, calibrated, vna_cfg)
self._last_processed_sweep = latest.sweep_number
if latest_sweep and latest_sweep.sweep_number > self._last_processed_sweep: # Light-duty polling to reduce wakeups
# Apply calibration
calibrated_data = self._apply_calibration(latest_sweep)
# Get VNA configuration
vna_config = self.settings_manager.get_current_preset()
# Process through all processors (results handled by callbacks)
self.process_sweep(latest_sweep, calibrated_data, vna_config)
self._last_processed_sweep = latest_sweep.sweep_number
# Check every 50ms
self._stop_event.wait(0.05) self._stop_event.wait(0.05)
except Exception as exc: # noqa: BLE001
except Exception as e: logger.error("Error in processing loop", error=repr(exc))
self.logger.error(f"Error in processing loop: {e}")
self._stop_event.wait(0.1) self._stop_event.wait(0.1)
# --------------------------------------------------------------------- #
# Calibration
# --------------------------------------------------------------------- #
def _apply_calibration(self, sweep_data: SweepData) -> SweepData: def _apply_calibration(self, sweep_data: SweepData) -> SweepData:
"""Apply calibration to sweep data""" """
Apply calibration to the sweep when a complete set is available.
Returns the original sweep on failure or when no calibration is present.
"""
try: try:
# Get current calibration set through settings manager calib_set = self.settings_manager.get_current_calibration()
calibration_set = self.settings_manager.get_current_calibration() if calib_set and calib_set.is_complete():
if calibration_set and calibration_set.is_complete(): points = self.calibration_processor.apply_calibration(sweep_data, calib_set)
# Apply calibration using our calibration processor calibrated = SweepData(
calibrated_points = self.calibration_processor.apply_calibration(sweep_data, calibration_set)
return SweepData(
sweep_number=sweep_data.sweep_number, sweep_number=sweep_data.sweep_number,
timestamp=sweep_data.timestamp, timestamp=sweep_data.timestamp,
points=calibrated_points, points=points,
total_points=len(calibrated_points) total_points=len(points),
) )
except Exception as e: logger.debug(
self.logger.error(f"Calibration failed: {e}") "Sweep calibrated",
sweep_number=calibrated.sweep_number,
points=calibrated.total_points,
)
return calibrated
except Exception as exc: # noqa: BLE001
logger.error("Calibration failed", error=repr(exc))
# Return original data if calibration fails or not available # Fallback: return the original data
return sweep_data return sweep_data
# --------------------------------------------------------------------- #
# Defaults
# --------------------------------------------------------------------- #
def _register_default_processors(self) -> None:
"""
Attempt to import and register default processors.
This is best-effort: if anything fails (e.g., module not present),
we log an error and keep going with whatever is available.
"""
try:
from .implementations import MagnitudeProcessor, PhaseProcessor, SmithChartProcessor
self.register_processor(MagnitudeProcessor(self.config_dir))
self.register_processor(PhaseProcessor(self.config_dir))
# self.register_processor(SmithChartProcessor(self.config_dir))
logger.info("Default processors registered", count=len(self._processors))
except Exception as exc: # noqa: BLE001
logger.error("Failed to register default processors", error=repr(exc))

View File

@ -1,94 +1,114 @@
from typing import Dict, Any, Set, Optional
import json
import asyncio import asyncio
import logging import json
from dataclasses import asdict
from datetime import datetime from datetime import datetime
from typing import Any
from fastapi import WebSocket, WebSocketDisconnect from fastapi import WebSocket, WebSocketDisconnect
from vna_system.core.logging.logger import get_component_logger
from vna_system.core.processors.base_processor import ProcessedResult from vna_system.core.processors.base_processor import ProcessedResult
from vna_system.core.processors.manager import ProcessorManager from vna_system.core.processors.manager import ProcessorManager
from vna_system.core.processors.storage import DataStorage from vna_system.core.processors.storage import DataStorage
logger = get_component_logger(__file__)
class ProcessorWebSocketHandler: class ProcessorWebSocketHandler:
""" """
Handles incoming websocket messages and broadcasts processor results WebSocket hub for processor results.
to all connected clients. Safe to call from non-async threads via
_broadcast_result_sync(). Responsibilities
----------------
• Accept and manage client connections.
• Handle client commands (e.g., recalculate, fetch history).
• Broadcast `ProcessedResult` objects to all connected clients.
• Bridge results produced on worker threads into the main asyncio loop.
Threading model
---------------
- Processor callbacks arrive on non-async worker threads.
- We capture the main running event loop when the first client connects.
- Cross-thread scheduling uses `asyncio.run_coroutine_threadsafe`.
""" """
def __init__(self, processor_manager: ProcessorManager, data_storage: DataStorage): def __init__(self, processor_manager: ProcessorManager, data_storage: DataStorage) -> None:
self.processor_manager = processor_manager self.processor_manager = processor_manager
self.data_storage = data_storage self.data_storage = data_storage
self.active_connections: Set[WebSocket] = set()
self.logger = logging.getLogger(__name__)
# Главный (running) event loop FastAPI/uvicorn. self.active_connections: set[WebSocket] = set()
# Устанавливается при принятии первого соединения.
self._loop: Optional[asyncio.AbstractEventLoop] = None
# Регистрируемся как колбэк на готовые результаты процессоров # Main FastAPI/uvicorn event loop handle (set on first connection).
self._loop: asyncio.AbstractEventLoop | None = None
# Subscribe to processor results (thread-safe callback).
self.processor_manager.add_result_callback(self._on_processor_result) self.processor_manager.add_result_callback(self._on_processor_result)
logger.debug("ProcessorWebSocketHandler initialized")
# --------------- Публичные async-обработчики входящих сообщений --------------- # --------------------------------------------------------------------- #
# Connection lifecycle
# --------------------------------------------------------------------- #
async def handle_websocket_connection(self, websocket: WebSocket) -> None:
"""
Accept a WebSocket, then serve client messages until disconnect.
async def handle_websocket_connection(self, websocket: WebSocket): Stores a reference to the running event loop so worker threads can
safely schedule broadcasts into it.
""" """
Accepts a websocket and serves messages until disconnect.
Сохраняет ссылку на главный running loop, чтобы из других потоков
можно было безопасно шедулить корутины.
"""
# Сохраним ссылку на активный loop (гарантированно внутри async-контекста)
if self._loop is None: if self._loop is None:
try: self._loop = asyncio.get_running_loop()
self._loop = asyncio.get_running_loop() logger.info("Main event loop captured for broadcasts")
self.logger.info("Stored main event loop reference for broadcasting")
except RuntimeError:
# Теоретически маловероятно здесь
self.logger.warning("Could not obtain running loop; broadcasts may be skipped")
await websocket.accept() await websocket.accept()
self.active_connections.add(websocket) self.active_connections.add(websocket)
self.logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}") logger.info("WebSocket connected", total_connections=len(self.active_connections))
try: try:
while True: while True:
data = await websocket.receive_text() raw = await websocket.receive_text()
await self.handle_message(websocket, data) await self.handle_message(websocket, raw)
except WebSocketDisconnect: except WebSocketDisconnect:
await self.disconnect(websocket) await self.disconnect(websocket)
except Exception as e: except Exception as exc: # noqa: BLE001
self.logger.error(f"WebSocket error: {e}") logger.error("WebSocket error", error=repr(exc))
await self.disconnect(websocket) await self.disconnect(websocket)
async def handle_message(self, websocket: WebSocket, data: str): async def disconnect(self, websocket: WebSocket) -> None:
try: """Remove a connection and log the updated count."""
message = json.loads(data)
message_type = message.get('type')
if message_type == 'recalculate':
await self._handle_recalculate(websocket, message)
elif message_type == 'get_history':
await self._handle_get_history(websocket, message)
else:
await self._send_error(websocket, f"Unknown message type: {message_type}")
except json.JSONDecodeError:
await self._send_error(websocket, "Invalid JSON format")
except Exception as e:
self.logger.exception("Error handling websocket message")
await self._send_error(websocket, f"Internal error: {str(e)}")
async def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections: if websocket in self.active_connections:
self.active_connections.remove(websocket) self.active_connections.remove(websocket)
self.logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}") logger.info("WebSocket disconnected", total_connections=len(self.active_connections))
# --------------- Команды клиента --------------- # --------------------------------------------------------------------- #
# Inbound messages
# --------------------------------------------------------------------- #
async def handle_message(self, websocket: WebSocket, data: str) -> None:
"""Parse and route an inbound client message."""
try:
message = json.loads(data)
mtype = message.get("type")
async def _handle_recalculate(self, websocket: WebSocket, message: Dict[str, Any]): if mtype == "recalculate":
processor_id = message.get('processor_id') await self._handle_recalculate(websocket, message)
config_updates = message.get('config_updates') elif mtype == "get_history":
await self._handle_get_history(websocket, message)
else:
await self._send_error(websocket, f"Unknown message type: {mtype!r}")
except json.JSONDecodeError:
await self._send_error(websocket, "Invalid JSON format")
except Exception as exc: # noqa: BLE001
logger.error("Error handling websocket message")
await self._send_error(websocket, f"Internal error: {exc}")
# --------------------------------------------------------------------- #
# Client commands
# --------------------------------------------------------------------- #
async def _handle_recalculate(self, websocket: WebSocket, message: dict[str, Any]) -> None:
"""
Recalculate a processor (optionally with config updates) and send the result back.
"""
processor_id = message.get("processor_id")
config_updates = message.get("config_updates")
if not processor_id: if not processor_id:
await self._send_error(websocket, "processor_id is required") await self._send_error(websocket, "processor_id is required")
@ -97,18 +117,19 @@ class ProcessorWebSocketHandler:
try: try:
result = self.processor_manager.recalculate_processor(processor_id, config_updates) result = self.processor_manager.recalculate_processor(processor_id, config_updates)
if result: if result:
response = self._result_to_message(processor_id, result) await websocket.send_text(json.dumps(self._result_to_message(processor_id, result)))
await websocket.send_text(json.dumps(response))
else: else:
await self._send_error(websocket, f"No result from processor {processor_id}") await self._send_error(websocket, f"No result from processor {processor_id}")
except Exception as exc: # noqa: BLE001
logger.error("Recalculation failed")
await self._send_error(websocket, f"Recalculation failed: {exc}")
except Exception as e: async def _handle_get_history(self, websocket: WebSocket, message: dict[str, Any]) -> None:
self.logger.exception("Recalculation failed") """
await self._send_error(websocket, f"Recalculation failed: {str(e)}") Fetch recent results history for a given processor and send it to the client.
"""
async def _handle_get_history(self, websocket: WebSocket, message: Dict[str, Any]): processor_id = message.get("processor_id")
processor_id = message.get('processor_id') limit = int(message.get("limit", 10))
limit = message.get('limit', 10)
if not processor_id: if not processor_id:
await self._send_error(websocket, "processor_id is required") await self._send_error(websocket, "processor_id is required")
@ -117,108 +138,114 @@ class ProcessorWebSocketHandler:
try: try:
history = self.data_storage.get_results_history(processor_id, limit) history = self.data_storage.get_results_history(processor_id, limit)
response = { response = {
'type': 'processor_history', "type": "processor_history",
'processor_id': processor_id, "processor_id": processor_id,
'history': [ "history": [
{ {
'timestamp': r.timestamp, "timestamp": r.timestamp,
'data': r.data, "data": r.data,
'plotly_config': r.plotly_config, "plotly_config": r.plotly_config,
'metadata': r.metadata "metadata": r.metadata,
} }
for r in history for r in history
] ],
} }
await websocket.send_text(json.dumps(response)) await websocket.send_text(json.dumps(response))
except Exception as exc: # noqa: BLE001
logger.error("Error getting history")
await self._send_error(websocket, f"Error getting history: {exc}")
except Exception as e: # --------------------------------------------------------------------- #
self.logger.exception("Error getting history") # Outbound helpers
await self._send_error(websocket, f"Error getting history: {str(e)}") # --------------------------------------------------------------------- #
def _result_to_message(self, processor_id: str, result: ProcessedResult) -> dict[str, Any]:
# --------------- Служебные методы --------------- """Convert a `ProcessedResult` into a JSON-serializable message."""
def _result_to_message(self, processor_id: str, result: ProcessedResult) -> Dict[str, Any]:
return { return {
'type': 'processor_result', "type": "processor_result",
'processor_id': processor_id, "processor_id": processor_id,
'timestamp': result.timestamp, "timestamp": result.timestamp,
'data': result.data, "data": result.data,
'plotly_config': result.plotly_config, "plotly_config": result.plotly_config,
'ui_parameters': [param.__dict__ for param in result.ui_parameters], "ui_parameters": [asdict(param) for param in result.ui_parameters],
'metadata': result.metadata "metadata": result.metadata,
} }
async def _send_error(self, websocket: WebSocket, message: str): async def _send_error(self, websocket: WebSocket, message: str) -> None:
"""Send a standardized error payload to a single client."""
try: try:
response = { payload = {
'type': 'error', "type": "error",
'message': message, "message": message,
'timestamp': datetime.now().timestamp() "timestamp": datetime.now().timestamp(),
} }
await websocket.send_text(json.dumps(response)) await websocket.send_text(json.dumps(payload))
except Exception as e: except Exception as exc: # noqa: BLE001
self.logger.error(f"Error sending error message: {e}") logger.error("Error sending error message", error=repr(exc))
# --------------- Получение результатов из процессоров (из другого потока) --------------- # --------------------------------------------------------------------- #
# Result callback bridge (worker thread -> asyncio loop)
# --------------------------------------------------------------------- #
def _on_processor_result(self, processor_id: str, result: ProcessedResult) -> None:
"""
Callback invoked on a worker thread when a processor produces a result.
def _on_processor_result(self, processor_id: str, result: ProcessedResult): We:
- Store the result synchronously in `DataStorage`.
- Schedule a coroutine on the main event loop to broadcast to clients.
""" """
Колбэк вызывается из потока обработки свипов (не из asyncio loop). # Best-effort persistence
Здесь нельзя напрямую await'ить — нужно перепоручить рассылку в главный loop.
"""
# Сохраняем результат в хранилище (синхронно)
try: try:
self.data_storage.store_result(processor_id, result) self.data_storage.store_result(processor_id, result)
except Exception: except Exception: # noqa: BLE001
self.logger.exception("Failed to store processor result") logger.error("Failed to store processor result")
# Рассылаем клиентам # Broadcast to clients
self._broadcast_result_sync(processor_id, result) self._broadcast_result_sync(processor_id, result)
def _broadcast_result_sync(self, processor_id: str, result: ProcessedResult): def _broadcast_result_sync(self, processor_id: str, result: ProcessedResult) -> None:
""" """
Потокобезопасная рассылка в активный event loop. Thread-safe broadcast entrypoint from worker threads.
Вызывается из НЕ-async потока.
Serializes once and schedules `_send_to_connections(...)` on the main loop.
""" """
if not self.active_connections: if not self.active_connections:
return return
# Подготовим строку JSON один раз
message_str = json.dumps(self._result_to_message(processor_id, result))
loop = self._loop loop = self._loop
if loop is None or not loop.is_running(): if loop is None or not loop.is_running():
# Луп ещё не был сохранён (нет подключений) или уже остановлен logger.debug("No running event loop available for broadcast; skipping")
self.logger.debug("No running event loop available for broadcast; skipping")
return return
try: try:
# Перекидываем корутину в главный loop из стороннего потока message_str = json.dumps(self._result_to_message(processor_id, result))
fut = asyncio.run_coroutine_threadsafe(self._send_to_connections(message_str), loop) except Exception as exc: # noqa: BLE001
# Опционально: можно добавить обработку результата/исключений: logger.error(f"Failed to serialize result for broadcast, {processor_id=}", error=repr(exc))
# fut.add_done_callback(lambda f: f.exception() and self.logger.error(f"Broadcast error: {f.exception()}")) return
except Exception as e:
self.logger.error(f"Failed to schedule broadcast: {e}")
async def _send_to_connections(self, message_str: str): try:
asyncio.run_coroutine_threadsafe(self._send_to_connections(message_str), loop)
except Exception as exc: # noqa: BLE001
logger.error("Failed to schedule broadcast", error=repr(exc))
async def _send_to_connections(self, message_str: str) -> None:
""" """
Реальная рассылка по всем активным соединениям (внутри event loop). Broadcast a pre-serialized JSON string to all active connections.
Removes connections that fail during send.
""" """
if not self.active_connections: if not self.active_connections:
return return
disconnected = [] disconnected: list[WebSocket] = []
# Снимок, чтобы итерация была стабильной for websocket in list(self.active_connections): # snapshot
for websocket in list(self.active_connections):
try: try:
await websocket.send_text(message_str) await websocket.send_text(message_str)
except Exception as e: except Exception as exc: # noqa: BLE001
self.logger.error(f"Error broadcasting to a websocket: {e}") logger.error("Broadcast to client failed; marking for disconnect", error=repr(exc))
disconnected.append(websocket) disconnected.append(websocket)
# Очистим отключившиеся
for websocket in disconnected: for websocket in disconnected:
try: try:
await self.disconnect(websocket) await self.disconnect(websocket)
except Exception as e: except Exception as exc: # noqa: BLE001
self.logger.error(f"Error during disconnect cleanup: {e}") logger.error("Error during disconnect cleanup", error=repr(exc))

View File

@ -1,18 +1,19 @@
from __future__ import annotations
import json import json
import shutil import shutil
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Dict, List
from vna_system.core import config as cfg from vna_system.core import config as cfg
from vna_system.core.logging.logger import get_component_logger
from vna_system.core.acquisition.sweep_buffer import SweepData from vna_system.core.acquisition.sweep_buffer import SweepData
from .preset_manager import ConfigPreset, VNAMode from vna_system.core.settings.preset_manager import ConfigPreset, VNAMode
logger = get_component_logger(__file__)
class CalibrationStandard(Enum): class CalibrationStandard(Enum):
"""Supported calibration standards."""
OPEN = "open" OPEN = "open"
SHORT = "short" SHORT = "short"
LOAD = "load" LOAD = "load"
@ -20,291 +21,354 @@ class CalibrationStandard(Enum):
class CalibrationSet: class CalibrationSet:
def __init__(self, preset: ConfigPreset, name: str = ""): """
In-memory container of calibration measurements for a specific VNA preset.
A set is *complete* when all standards required by the preset mode are present:
- S11: OPEN, SHORT, LOAD
- S21: THROUGH
"""
def __init__(self, preset: ConfigPreset, name: str = "") -> None:
self.preset = preset self.preset = preset
self.name = name self.name = name
self.standards: Dict[CalibrationStandard, SweepData] = {} self.standards: dict[CalibrationStandard, SweepData] = {}
def add_standard(self, standard: CalibrationStandard, sweep_data: SweepData): # ------------------------------ mutation ------------------------------ #
"""Add calibration data for specific standard""" def add_standard(self, standard: CalibrationStandard, sweep_data: SweepData) -> None:
"""Attach sweep data for a given standard."""
self.standards[standard] = sweep_data self.standards[standard] = sweep_data
logger.debug("Calibration standard added", standard=standard.value, points=sweep_data.total_points)
def remove_standard(self, standard: CalibrationStandard): def remove_standard(self, standard: CalibrationStandard) -> None:
"""Remove calibration data for specific standard""" """Remove sweep data for a given standard if present."""
if standard in self.standards: if standard in self.standards:
del self.standards[standard] del self.standards[standard]
logger.debug("Calibration standard removed", standard=standard.value)
# ------------------------------ queries -------------------------------- #
def has_standard(self, standard: CalibrationStandard) -> bool: def has_standard(self, standard: CalibrationStandard) -> bool:
"""Check if standard is present in calibration set""" """Return True if the standard is present in the set."""
return standard in self.standards return standard in self.standards
def is_complete(self) -> bool: def is_complete(self) -> bool:
"""Check if all required standards are present""" """Return True if all required standards for the preset mode are present."""
required_standards = self._get_required_standards() required = self._get_required_standards()
return all(std in self.standards for std in required_standards) complete = all(s in self.standards for s in required)
logger.debug("Calibration completeness checked", complete=complete, required=[s.value for s in required])
return complete
def get_missing_standards(self) -> List[CalibrationStandard]: def get_missing_standards(self) -> list[CalibrationStandard]:
"""Get list of missing required standards""" """List the standards still missing for a complete set."""
required_standards = self._get_required_standards() required = self._get_required_standards()
return [std for std in required_standards if std not in self.standards] return [s for s in required if s not in self.standards]
def get_progress(self) -> tuple[int, int]: def get_progress(self) -> tuple[int, int]:
"""Get calibration progress as (completed, total)""" """Return (completed, total_required) for progress display."""
required_standards = self._get_required_standards() required = self._get_required_standards()
completed = len([std for std in required_standards if std in self.standards]) completed = sum(1 for s in required if s in self.standards)
return completed, len(required_standards) return completed, len(required)
def _get_required_standards(self) -> List[CalibrationStandard]: # ------------------------------ internals ------------------------------ #
"""Get required calibration standards for preset mode""" def _get_required_standards(self) -> list[CalibrationStandard]:
"""Standards required by the current preset mode."""
if self.preset.mode == VNAMode.S11: if self.preset.mode == VNAMode.S11:
return [CalibrationStandard.OPEN, CalibrationStandard.SHORT, CalibrationStandard.LOAD] return [CalibrationStandard.OPEN, CalibrationStandard.SHORT, CalibrationStandard.LOAD]
elif self.preset.mode == VNAMode.S21: if self.preset.mode == VNAMode.S21:
return [CalibrationStandard.THROUGH] return [CalibrationStandard.THROUGH]
return [] return []
class CalibrationManager: class CalibrationManager:
def __init__(self, base_dir: Path | None = None): """
Filesystem-backed manager for calibration sets.
Layout
------
<BASE_DIR>/calibration/
├─ current_calibration -> <preset_dir>/<calibration_name>
├─ <preset_name>/
│ └─ <calibration_name>/
│ ├─ open.json / short.json / load.json / through.json
│ ├─ open_metadata.json ... (per-standard metadata)
│ └─ calibration_info.json (set-level metadata)
"""
def __init__(self, base_dir: Path | None = None) -> None:
self.base_dir = Path(base_dir or cfg.BASE_DIR) self.base_dir = Path(base_dir or cfg.BASE_DIR)
self.calibration_dir = self.base_dir / "calibration" self.calibration_dir = self.base_dir / "calibration"
self.current_calibration_symlink = self.calibration_dir / "current_calibration" self.current_calibration_symlink = self.calibration_dir / "current_calibration"
self.calibration_dir.mkdir(parents=True, exist_ok=True) self.calibration_dir.mkdir(parents=True, exist_ok=True)
# Current working calibration set
self._current_working_set: CalibrationSet | None = None self._current_working_set: CalibrationSet | None = None
logger.debug("CalibrationManager initialized", base_dir=str(self.base_dir))
# ------------------------------------------------------------------ #
# Working set lifecycle
# ------------------------------------------------------------------ #
def start_new_calibration(self, preset: ConfigPreset) -> CalibrationSet: def start_new_calibration(self, preset: ConfigPreset) -> CalibrationSet:
"""Start new calibration set for preset""" """Start a new, empty working set for a given preset."""
self._current_working_set = CalibrationSet(preset) self._current_working_set = CalibrationSet(preset)
logger.info("New calibration session started", preset=preset.filename, mode=preset.mode.value)
return self._current_working_set return self._current_working_set
def get_current_working_set(self) -> CalibrationSet | None: def get_current_working_set(self) -> CalibrationSet | None:
"""Get current working calibration set""" """Return the current working set (if any)."""
return self._current_working_set return self._current_working_set
def add_calibration_standard(self, standard: CalibrationStandard, sweep_data: SweepData): def add_calibration_standard(self, standard: CalibrationStandard, sweep_data: SweepData) -> None:
"""Add calibration standard to current working set""" """Add a standard measurement to the active working set."""
if self._current_working_set is None: if self._current_working_set is None:
raise RuntimeError("No active calibration set. Call start_new_calibration first.") raise RuntimeError("No active calibration set. Call start_new_calibration first.")
self._current_working_set.add_standard(standard, sweep_data) self._current_working_set.add_standard(standard, sweep_data)
def remove_calibration_standard(self, standard: CalibrationStandard): def remove_calibration_standard(self, standard: CalibrationStandard) -> None:
"""Remove calibration standard from current working set""" """Remove a standard measurement from the working set."""
if self._current_working_set is None: if self._current_working_set is None:
raise RuntimeError("No active calibration set.") raise RuntimeError("No active calibration set.")
self._current_working_set.remove_standard(standard) self._current_working_set.remove_standard(standard)
# ------------------------------------------------------------------ #
# Persistence
# ------------------------------------------------------------------ #
def save_calibration_set(self, calibration_name: str) -> CalibrationSet: def save_calibration_set(self, calibration_name: str) -> CalibrationSet:
"""Save current working calibration set to disk""" """
Persist the working set to disk under the preset directory.
Writes:
- per-standard sweeps as JSON
- per-standard metadata
- set-level metadata
"""
if self._current_working_set is None: if self._current_working_set is None:
raise RuntimeError("No active calibration set to save.") raise RuntimeError("No active calibration set to save.")
if not self._current_working_set.is_complete(): if not self._current_working_set.is_complete():
missing = self._current_working_set.get_missing_standards() missing = [s.value for s in self._current_working_set.get_missing_standards()]
raise ValueError(f"Calibration incomplete. Missing standards: {[s.value for s in missing]}") raise ValueError(f"Calibration incomplete. Missing standards: {missing}")
preset = self._current_working_set.preset preset = self._current_working_set.preset
preset_dir = self._get_preset_calibration_dir(preset) calib_dir = self._get_preset_calibration_dir(preset) / calibration_name
calib_dir = preset_dir / calibration_name
calib_dir.mkdir(parents=True, exist_ok=True) calib_dir.mkdir(parents=True, exist_ok=True)
# Save each standard # Save standards
for standard, sweep_data in self._current_working_set.standards.items(): for standard, sweep in self._current_working_set.standards.items():
# Save sweep data as JSON
sweep_json = { sweep_json = {
'sweep_number': sweep_data.sweep_number, "sweep_number": sweep.sweep_number,
'timestamp': sweep_data.timestamp, "timestamp": sweep.timestamp,
'points': sweep_data.points, "points": sweep.points,
'total_points': sweep_data.total_points "total_points": sweep.total_points,
} }
self._atomic_json_write(calib_dir / f"{standard.value}.json", sweep_json)
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 = { metadata = {
'preset': { "preset": {
'filename': preset.filename, "filename": preset.filename,
'mode': preset.mode.value, "mode": preset.mode.value,
'start_freq': preset.start_freq, "start_freq": preset.start_freq,
'stop_freq': preset.stop_freq, "stop_freq": preset.stop_freq,
'points': preset.points, "points": preset.points,
'bandwidth': preset.bandwidth "bandwidth": preset.bandwidth,
}, },
'calibration_name': calibration_name, "calibration_name": calibration_name,
'standard': standard.value, "standard": standard.value,
'sweep_number': sweep_data.sweep_number, "sweep_number": sweep.sweep_number,
'sweep_timestamp': sweep_data.timestamp, "sweep_timestamp": sweep.timestamp,
'created_timestamp': datetime.now().isoformat(), "created_timestamp": datetime.now().isoformat(),
'total_points': sweep_data.total_points "total_points": sweep.total_points,
} }
self._atomic_json_write(calib_dir / f"{standard.value}_metadata.json", metadata)
metadata_path = calib_dir / f"{standard.value}_metadata.json" # Save set-level metadata
with open(metadata_path, 'w') as f:
json.dump(metadata, f, indent=2)
# Save calibration set metadata
set_metadata = { set_metadata = {
'preset': { "preset": {
'filename': preset.filename, "filename": preset.filename,
'mode': preset.mode.value, "mode": preset.mode.value,
'start_freq': preset.start_freq, "start_freq": preset.start_freq,
'stop_freq': preset.stop_freq, "stop_freq": preset.stop_freq,
'points': preset.points, "points": preset.points,
'bandwidth': preset.bandwidth "bandwidth": preset.bandwidth,
}, },
'calibration_name': calibration_name, "calibration_name": calibration_name,
'standards': [std.value for std in self._current_working_set.standards.keys()], "standards": [s.value for s in self._current_working_set.standards],
'created_timestamp': datetime.now().isoformat(), "created_timestamp": datetime.now().isoformat(),
'is_complete': True "is_complete": True,
} }
self._atomic_json_write(calib_dir / "calibration_info.json", set_metadata)
set_metadata_path = calib_dir / "calibration_info.json" # Update working set name
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 self._current_working_set.name = calibration_name
logger.info("Calibration set saved", preset=preset.filename, name=calibration_name)
return self._current_working_set return self._current_working_set
def load_calibration_set(self, preset: ConfigPreset, calibration_name: str) -> CalibrationSet: def load_calibration_set(self, preset: ConfigPreset, calibration_name: str) -> CalibrationSet:
"""Load existing calibration set from disk""" """Load a calibration set from disk for the given preset."""
preset_dir = self._get_preset_calibration_dir(preset) preset_dir = self._get_preset_calibration_dir(preset)
calib_dir = preset_dir / calibration_name calib_dir = preset_dir / calibration_name
if not calib_dir.exists(): if not calib_dir.exists():
raise FileNotFoundError(f"Calibration not found: {calibration_name}") raise FileNotFoundError(f"Calibration not found: {calibration_name}")
calib_set = CalibrationSet(preset, calibration_name) calib_set = CalibrationSet(preset, calibration_name)
# Load all standard files
for standard in CalibrationStandard: for standard in CalibrationStandard:
file_path = calib_dir / f"{standard.value}.json" file_path = calib_dir / f"{standard.value}.json"
if file_path.exists(): if not file_path.exists():
with open(file_path, 'r') as f: continue
sweep_json = json.load(f) try:
data = json.loads(file_path.read_text(encoding="utf-8"))
sweep_data = SweepData( sweep = SweepData(
sweep_number=sweep_json['sweep_number'], sweep_number=int(data["sweep_number"]),
timestamp=sweep_json['timestamp'], timestamp=float(data["timestamp"]),
points=sweep_json['points'], points=[(float(r), float(i)) for r, i in data["points"]],
total_points=sweep_json['total_points'] total_points=int(data["total_points"]),
) )
calib_set.add_standard(standard, sweep)
except Exception as exc: # noqa: BLE001
logger.warning("Failed to load standard file", file=str(file_path), error=repr(exc))
calib_set.add_standard(standard, sweep_data) logger.info("Calibration set loaded", preset=preset.filename, name=calibration_name)
return calib_set return calib_set
def get_available_calibrations(self, preset: ConfigPreset) -> List[str]: # ------------------------------------------------------------------ #
"""Get list of available calibration sets for preset""" # Discovery & info
# ------------------------------------------------------------------ #
def get_available_calibrations(self, preset: ConfigPreset) -> list[str]:
"""Return sorted list of calibration set names available for a preset."""
preset_dir = self._get_preset_calibration_dir(preset) preset_dir = self._get_preset_calibration_dir(preset)
if not preset_dir.exists(): if not preset_dir.exists():
return [] return []
names = sorted([p.name for p in preset_dir.iterdir() if p.is_dir()])
logger.debug("Available calibrations listed", preset=preset.filename, count=len(names))
return names
calibrations = [] def get_calibration_info(self, preset: ConfigPreset, calibration_name: str) -> dict:
for item in preset_dir.iterdir(): """
if item.is_dir(): Return set-level info for a calibration (from cached metadata if present,
calibrations.append(item.name) or by scanning the directory as a fallback).
"""
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) preset_dir = self._get_preset_calibration_dir(preset)
calib_dir = preset_dir / calibration_name calib_dir = preset_dir / calibration_name
info_file = calib_dir / "calibration_info.json" info_file = calib_dir / "calibration_info.json"
if info_file.exists(): if info_file.exists():
with open(info_file, 'r') as f: try:
return json.load(f) return json.loads(info_file.read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001
logger.warning("Failed to read calibration_info.json; falling back to scan", error=repr(exc))
# Fallback: scan files required = self._get_required_standards(preset.mode)
standards = {} standards: dict[str, bool] = {}
required_standards = self._get_required_standards(preset.mode) for s in required:
standards[s.value] = (calib_dir / f"{s.value}.json").exists()
for standard in required_standards:
file_path = calib_dir / f"{standard.value}.json"
standards[standard.value] = file_path.exists()
return { return {
'calibration_name': calibration_name, "calibration_name": calibration_name,
'standards': standards, "standards": standards,
'is_complete': all(standards.values()) "is_complete": all(standards.values()),
} }
def set_current_calibration(self, preset: ConfigPreset, calibration_name: str): # ------------------------------------------------------------------ #
"""Set current calibration by creating symlink""" # Current calibration (symlink)
# ------------------------------------------------------------------ #
def set_current_calibration(self, preset: ConfigPreset, calibration_name: str) -> None:
"""Point the `current_calibration` symlink to the chosen calibration dir."""
preset_dir = self._get_preset_calibration_dir(preset) preset_dir = self._get_preset_calibration_dir(preset)
calib_dir = preset_dir / calibration_name calib_dir = preset_dir / calibration_name
if not calib_dir.exists(): if not calib_dir.exists():
raise FileNotFoundError(f"Calibration not found: {calibration_name}") raise FileNotFoundError(f"Calibration not found: {calibration_name}")
# Check if calibration is complete
info = self.get_calibration_info(preset, calibration_name) info = self.get_calibration_info(preset, calibration_name)
if not info.get('is_complete', False): if not info.get("is_complete", False):
raise ValueError(f"Calibration {calibration_name} is incomplete") raise ValueError(f"Calibration {calibration_name} is incomplete")
# Remove existing symlink if present # Refresh symlink
if self.current_calibration_symlink.exists() or self.current_calibration_symlink.is_symlink():
self.current_calibration_symlink.unlink()
# Create new symlink
try: try:
relative_path = calib_dir.relative_to(self.calibration_dir) if self.current_calibration_symlink.exists() or self.current_calibration_symlink.is_symlink():
except ValueError: self.current_calibration_symlink.unlink()
relative_path = calib_dir except Exception as exc: # noqa: BLE001
logger.warning("Failed to remove existing current_calibration link", error=repr(exc))
self.current_calibration_symlink.symlink_to(relative_path) try:
# Create a relative link when possible to keep the tree portable
relative = calib_dir
try:
relative = calib_dir.relative_to(self.calibration_dir)
except ValueError:
pass
self.current_calibration_symlink.symlink_to(relative)
logger.info("Current calibration set", preset=preset.filename, name=calibration_name)
except Exception as exc: # noqa: BLE001
logger.error("Failed to create current_calibration symlink", error=repr(exc))
raise
def get_current_calibration(self, current_preset: ConfigPreset) -> CalibrationSet | None: def get_current_calibration(self, current_preset: ConfigPreset) -> CalibrationSet | None:
"""Get currently selected calibration as CalibrationSet""" """
Resolve and load the calibration currently pointed to by the symlink.
Returns None if the link doesn't exist, points to an invalid location,
or targets a different preset.
"""
if not self.current_calibration_symlink.exists(): if not self.current_calibration_symlink.exists():
return None return None
try: try:
target = self.current_calibration_symlink.resolve() target = self.current_calibration_symlink.resolve()
calibration_name = target.name calibration_name = target.name
preset_name = target.parent.name preset_dir_name = target.parent.name # <preset_name> (without .bin)
# If current_preset matches, use it expected_preset_name = current_preset.filename.replace(".bin", "")
if current_preset.filename == f"{preset_name}.bin": if preset_dir_name != expected_preset_name:
return self.load_calibration_set(current_preset, calibration_name) logger.warning(
else: "Current calibration preset mismatch",
raise RuntimeError("Current calibration is set and is meant for different preset.") expected=expected_preset_name,
actual=preset_dir_name,
)
raise RuntimeError("Current calibration belongs to a different preset")
except Exception: return self.load_calibration_set(current_preset, calibration_name)
except Exception as exc: # noqa: BLE001
logger.warning("Failed to resolve current calibration", error=repr(exc))
return None return None
def clear_current_calibration(self): def clear_current_calibration(self) -> None:
"""Clear current calibration symlink""" """Remove the current calibration symlink."""
if self.current_calibration_symlink.exists() or self.current_calibration_symlink.is_symlink(): if self.current_calibration_symlink.exists() or self.current_calibration_symlink.is_symlink():
self.current_calibration_symlink.unlink() try:
self.current_calibration_symlink.unlink()
def delete_calibration(self, preset: ConfigPreset, calibration_name: str): logger.info("Current calibration cleared")
"""Delete calibration set""" except Exception as exc: # noqa: BLE001
preset_dir = self._get_preset_calibration_dir(preset) logger.warning("Failed to clear current calibration", error=repr(exc))
calib_dir = preset_dir / calibration_name
# ------------------------------------------------------------------ #
# Deletion
# ------------------------------------------------------------------ #
def delete_calibration(self, preset: ConfigPreset, calibration_name: str) -> None:
"""Delete a calibration set directory."""
calib_dir = self._get_preset_calibration_dir(preset) / calibration_name
if calib_dir.exists(): if calib_dir.exists():
shutil.rmtree(calib_dir) shutil.rmtree(calib_dir)
logger.info("Calibration deleted", preset=preset.filename, name=calibration_name)
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _get_preset_calibration_dir(self, preset: ConfigPreset) -> Path: def _get_preset_calibration_dir(self, preset: ConfigPreset) -> Path:
"""Get calibration directory for specific preset""" """Return the directory where calibrations for this preset are stored."""
preset_dir = self.calibration_dir / preset.filename.replace('.bin', '') preset_dir = self.calibration_dir / preset.filename.replace(".bin", "")
preset_dir.mkdir(parents=True, exist_ok=True) preset_dir.mkdir(parents=True, exist_ok=True)
return preset_dir return preset_dir
def _get_required_standards(self, mode: VNAMode) -> List[CalibrationStandard]: def _get_required_standards(self, mode: VNAMode) -> list[CalibrationStandard]:
"""Get required calibration standards for VNA mode""" """Standards required for a given VNA mode."""
if mode == VNAMode.S11: if mode == VNAMode.S11:
return [CalibrationStandard.OPEN, CalibrationStandard.SHORT, CalibrationStandard.LOAD] return [CalibrationStandard.OPEN, CalibrationStandard.SHORT, CalibrationStandard.LOAD]
elif mode == VNAMode.S21: if mode == VNAMode.S21:
return [CalibrationStandard.THROUGH] return [CalibrationStandard.THROUGH]
return [] return []
@staticmethod
def _atomic_json_write(path: Path, payload: dict) -> None:
"""Write JSON atomically via a temporary sidecar file."""
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)

View File

@ -1,21 +1,40 @@
from __future__ import annotations
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import List
from vna_system.core import config as cfg from vna_system.core import config as cfg
from vna_system.core.logging.logger import get_component_logger
logger = get_component_logger(__file__)
class VNAMode(Enum): class VNAMode(Enum):
"""Supported VNA measurement modes."""
S11 = "s11" S11 = "s11"
S21 = "s21" S21 = "s21"
@dataclass @dataclass(slots=True)
class ConfigPreset: class ConfigPreset:
"""
Parsed configuration preset derived from a filename.
Fields
------
filename:
Original filename (e.g., 's11_start100_stop8800_points1000_bw1khz.bin').
mode:
VNA mode (S11 or S21).
start_freq:
Start frequency in Hz (None if not provided in the filename).
stop_freq:
Stop frequency in Hz (None if not provided in the filename).
points:
Number of sweep points (None if not provided).
bandwidth:
IF bandwidth in Hz (None if not provided).
"""
filename: str filename: str
mode: VNAMode mode: VNAMode
start_freq: float | None = None start_freq: float | None = None
@ -25,105 +44,187 @@ class ConfigPreset:
class PresetManager: 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") Discover, parse, and manage configuration presets stored on disk.
Directory layout
----------------
<BASE_DIR>/vna_system/binary_input/
├─ config_inputs/
│ └─ *.bin (preset files; configuration encoded in filename)
└─ current_input.bin -> config_inputs/<chosen>.bin
Filenames encode parameters, e.g.:
s11_start100_stop8800_points1000_bw1khz.bin
s21_start0.1ghz_stop3.0ghz_points1001_bw10kHz.bin
Parsing rules
-------------
- Mode must be the prefix: 's11' or 's21'
- start/stop: numbers with optional unit suffix (hz|khz|mhz|ghz). If no suffix, defaults to MHz.
- points: integer (points or point also accepted)
- bw: number with optional unit suffix (hz|khz|mhz). Defaults to Hz if absent.
"""
def __init__(self, binary_input_dir: Path | None = 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.config_inputs_dir = self.binary_input_dir / "config_inputs"
self.current_input_symlink = self.binary_input_dir / "current_input.bin" self.current_input_symlink = self.binary_input_dir / "current_input.bin"
self.config_inputs_dir.mkdir(parents=True, exist_ok=True) self.config_inputs_dir.mkdir(parents=True, exist_ok=True)
logger.debug(
"PresetManager initialized",
binary_input=str(self.binary_input_dir),
config_inputs=str(self.config_inputs_dir),
)
def _parse_filename(self, filename: str) -> ConfigPreset | None: # ------------------------------------------------------------------ #
"""Parse configuration parameters from filename like s11_start100_stop8800_points1000_bw1khz.bin""" # Parsing
base_name = Path(filename).stem.lower() # ------------------------------------------------------------------ #
def _parse_filename(self, filename: str) -> ConfigPreset | None: # type: ignore[name-defined]
"""
Parse configuration parameters from a preset filename.
# Extract mode - must be at the beginning Accepted fragments (case-insensitive):
mode = None - ^s11 or ^s21
if base_name.startswith('s11'): - start<value><unit?>
- stop<value><unit?>
- points?<int>
- bw<value><unit?>
Units
-----
- For start/stop: hz|khz|mhz|ghz (default: MHz when absent)
- For bw: hz|khz|mhz (default: Hz when absent)
"""
base = Path(filename).stem.lower()
# Mode at the beginning
if base.startswith("s11"):
mode = VNAMode.S11 mode = VNAMode.S11
elif base_name.startswith('s21'): elif base.startswith("s21"):
mode = VNAMode.S21 mode = VNAMode.S21
else: else:
logger.debug("Filename does not start with mode token", filename=filename)
return None return None
preset = ConfigPreset(filename=filename, mode=mode) preset = ConfigPreset(filename=filename, mode=mode)
# Extract parameters using regex # Patterns with optional unit suffixes
patterns = { pat_start = r"start(?P<val>\d+(?:\.\d+)?)(?P<unit>hz|khz|mhz|ghz)?"
'start': r'start(\d+(?:\.\d+)?)', pat_stop = r"stop(?P<val>\d+(?:\.\d+)?)(?P<unit>hz|khz|mhz|ghz)?"
'stop': r'stop(\d+(?:\.\d+)?)', pat_points = r"points?(?P<val>\d+)"
'points': r'points?(\d+)', pat_bw = r"bw(?P<val>\d+(?:\.\d+)?)(?P<unit>hz|khz|mhz)?"
'bw': r'bw(\d+(?:\.\d+)?)(hz|khz|mhz)?'
}
for param, pattern in patterns.items(): def _match(pattern: str) -> re.Match[str] | None:
match = re.search(pattern, base_name) return re.search(pattern, base, flags=re.IGNORECASE)
if match:
value = float(match.group(1))
if param == 'start': m = _match(pat_start)
# Assume MHz if no unit specified if m:
preset.start_freq = value * 1e6 preset.start_freq = self._to_hz(float(m.group("val")), (m.group("unit") or "mhz"))
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
m = _match(pat_stop)
if m:
preset.stop_freq = self._to_hz(float(m.group("val")), (m.group("unit") or "mhz"))
m = _match(pat_points)
if m:
preset.points = int(m.group("val"))
m = _match(pat_bw)
if m:
preset.bandwidth = self._to_hz(float(m.group("val")), (m.group("unit") or "hz"))
logger.debug(
"Preset filename parsed",
filename=filename,
start=preset.start_freq,
stop=preset.stop_freq,
points=preset.points,
bw=preset.bandwidth,
)
return preset return preset
def get_available_presets(self) -> List[ConfigPreset]: @staticmethod
"""Return list of all available configuration presets""" def _to_hz(value: float, unit: str) -> float:
presets = [] """Convert a numeric value with textual unit into Hz."""
u = unit.lower()
if u == "hz":
return value
if u == "khz":
return value * 1e3
if u == "mhz":
return value * 1e6
if u == "ghz":
return value * 1e9
# Fallback: treat as Hz if unknown
return value
# ------------------------------------------------------------------ #
# Discovery & selection
# ------------------------------------------------------------------ #
def get_available_presets(self) -> list[ConfigPreset]:
"""
Return a list of available presets discovered in `config_inputs_dir`.
Only files that parse successfully are returned.
"""
presets: list[ConfigPreset] = []
if not self.config_inputs_dir.exists(): if not self.config_inputs_dir.exists():
return presets 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) for path in self.config_inputs_dir.glob("*.bin"):
p = self._parse_filename(path.name)
if p is not None:
presets.append(p)
presets.sort(key=lambda x: x.filename.lower())
logger.debug("Available presets enumerated", count=len(presets))
return presets
def set_current_preset(self, preset: ConfigPreset) -> ConfigPreset: 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 Select a preset by (re)pointing `current_input.bin` to the chosen file.
"""
if not preset_path.exists(): src = self.config_inputs_dir / preset.filename
if not src.exists():
raise FileNotFoundError(f"Preset file not found: {preset.filename}") raise FileNotFoundError(f"Preset file not found: {preset.filename}")
# Remove existing symlink if present # Remove any existing link/file
if self.current_input_symlink.exists() or self.current_input_symlink.is_symlink(): if self.current_input_symlink.exists() or self.current_input_symlink.is_symlink():
self.current_input_symlink.unlink() try:
self.current_input_symlink.unlink()
except Exception as exc: # noqa: BLE001
logger.warning("Failed to remove existing current_input.bin", error=repr(exc))
# Create new symlink # Prefer a relative symlink for portability
try: try:
relative_path = preset_path.relative_to(self.binary_input_dir) target = src.relative_to(self.binary_input_dir)
except ValueError: except ValueError:
relative_path = preset_path target = src
self.current_input_symlink.symlink_to(relative_path)
self.current_input_symlink.symlink_to(target)
logger.info("Current preset set", filename=preset.filename)
return preset return preset
def get_current_preset(self) -> ConfigPreset | None: def get_current_preset(self) -> ConfigPreset | None:
"""Get currently selected configuration preset""" """
Resolve the `current_input.bin` symlink and parse the underlying preset.
Returns None if the symlink is missing or the filename cannot be parsed.
"""
if not self.current_input_symlink.exists(): if not self.current_input_symlink.exists():
return None return None
try: try:
target = self.current_input_symlink.resolve() target = self.current_input_symlink.resolve()
return self._parse_filename(target.name) return self._parse_filename(target.name)
except Exception: except Exception as exc: # noqa: BLE001
logger.warning("Failed to resolve current preset", error=repr(exc))
return None return None
def preset_exists(self, preset: ConfigPreset) -> bool: def preset_exists(self, preset: ConfigPreset) -> bool:
"""Check if preset file exists""" """Return True if the underlying file for `preset` exists."""
return (self.config_inputs_dir / preset.filename).exists() exists = (self.config_inputs_dir / preset.filename).exists()
logger.debug("Preset existence checked", filename=preset.filename, exists=exists)
return exists

View File

@ -1,124 +1,194 @@
from __future__ import annotations
import logging
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Any
from vna_system.core import config as cfg from vna_system.core import config as cfg
from vna_system.core.logging.logger import get_component_logger
from vna_system.core.acquisition.data_acquisition import VNADataAcquisition
from vna_system.core.acquisition.sweep_buffer import SweepData from vna_system.core.acquisition.sweep_buffer import SweepData
from .preset_manager import PresetManager, ConfigPreset, VNAMode from vna_system.core.settings.preset_manager import PresetManager, ConfigPreset, VNAMode
from .calibration_manager import CalibrationManager, CalibrationSet, CalibrationStandard from vna_system.core.settings.calibration_manager import (
CalibrationManager,
CalibrationSet,
CalibrationStandard,
)
logger = logging.getLogger(__name__) logger = get_component_logger(__file__)
class VNASettingsManager: class VNASettingsManager:
""" """
Main settings manager that coordinates preset and calibration management. High-level coordinator for presets and calibrations.
Provides high-level interface for: Responsibilities
- Managing configuration presets ----------------
- Managing calibration data • Discover/select configuration presets (via `PresetManager`).
- Coordinating between preset selection and calibration Create/store/select calibration sets (via `CalibrationManager`).
• Provide combined status used by API/UI.
• Offer helpers to capture calibration directly from acquisition.
Notes
-----
- All IO paths are derived from `cfg.BASE_DIR`.
- Logging is performed via the project logger.
""" """
def __init__(self, base_dir: Path | None = None): def __init__(self, base_dir: Path | None = None) -> None:
self.base_dir = Path(base_dir or cfg.BASE_DIR) self.base_dir = Path(base_dir or cfg.BASE_DIR)
# Initialize sub-managers # Sub-managers
self.preset_manager = PresetManager(self.base_dir / "binary_input") self.preset_manager = PresetManager(self.base_dir / "binary_input")
self.calibration_manager = CalibrationManager(self.base_dir) self.calibration_manager = CalibrationManager(self.base_dir)
# ---------- Preset Management ---------- logger.debug(
"VNASettingsManager initialized",
base_dir=str(self.base_dir),
)
def get_available_presets(self) -> List[ConfigPreset]: # ------------------------------------------------------------------ #
"""Get all available configuration presets""" # Preset Management
# ------------------------------------------------------------------ #
def get_available_presets(self) -> list[ConfigPreset]:
"""Return all available configuration presets."""
return self.preset_manager.get_available_presets() return self.preset_manager.get_available_presets()
def get_presets_by_mode(self, mode: VNAMode) -> List[ConfigPreset]: def get_presets_by_mode(self, mode: VNAMode) -> list[ConfigPreset]:
"""Get presets filtered by VNA mode""" """Return presets filtered by VNA mode."""
all_presets = self.get_available_presets() return [p for p in self.get_available_presets() if p.mode == mode]
return [p for p in all_presets if p.mode == mode]
def set_current_preset(self, preset: ConfigPreset) -> ConfigPreset: def set_current_preset(self, preset: ConfigPreset) -> ConfigPreset:
"""Set current configuration preset""" """Set the current configuration preset (updates the symlink)."""
return self.preset_manager.set_current_preset(preset) chosen = self.preset_manager.set_current_preset(preset)
logger.info("Current preset selected", filename=chosen.filename, mode=chosen.mode.value)
return chosen
def get_current_preset(self) -> ConfigPreset | None: def get_current_preset(self) -> ConfigPreset | None:
"""Get currently selected preset""" """Return the currently selected preset, or None if not set."""
return self.preset_manager.get_current_preset() return self.preset_manager.get_current_preset()
# ---------- Calibration Management ---------- # ------------------------------------------------------------------ #
# Calibration Management
# ------------------------------------------------------------------ #
def start_new_calibration(self, preset: ConfigPreset | None = None) -> CalibrationSet: def start_new_calibration(self, preset: ConfigPreset | None = None) -> CalibrationSet:
"""Start new calibration for current or specified preset""" """
Begin a new in-memory calibration session for a preset.
If `preset` is omitted, the current preset must be set.
"""
preset = preset or self.get_current_preset()
if preset is None: if preset is None:
preset = self.get_current_preset() raise RuntimeError("No current preset selected")
if preset is None:
raise RuntimeError("No current preset selected")
return self.calibration_manager.start_new_calibration(preset)
calib = self.calibration_manager.start_new_calibration(preset)
logger.info("Calibration session started", preset=preset.filename, mode=preset.mode.value)
return calib
def get_current_working_calibration(self) -> CalibrationSet | None: def get_current_working_calibration(self) -> CalibrationSet | None:
"""Get current working calibration set (in-progress, not yet saved)""" """Return the in-progress (unsaved) calibration set, if any."""
return self.calibration_manager.get_current_working_set() return self.calibration_manager.get_current_working_set()
def add_calibration_standard(self, standard: CalibrationStandard, sweep_data: SweepData): def add_calibration_standard(self, standard: CalibrationStandard, sweep_data: SweepData) -> None:
"""Add calibration standard to current working set""" """Add a standard measurement to the working calibration set."""
self.calibration_manager.add_calibration_standard(standard, sweep_data) self.calibration_manager.add_calibration_standard(standard, sweep_data)
logger.info(
"Calibration standard added",
standard=standard.value,
sweep_number=sweep_data.sweep_number,
points=sweep_data.total_points,
)
def remove_calibration_standard(self, standard: CalibrationStandard): def remove_calibration_standard(self, standard: CalibrationStandard) -> None:
"""Remove calibration standard from current working set""" """Remove a standard measurement from the working calibration set."""
self.calibration_manager.remove_calibration_standard(standard) self.calibration_manager.remove_calibration_standard(standard)
logger.info("Calibration standard removed", standard=standard.value)
def save_calibration_set(self, calibration_name: str) -> CalibrationSet: def save_calibration_set(self, calibration_name: str) -> CalibrationSet:
"""Save current working calibration set""" """Persist the current working calibration set to disk."""
return self.calibration_manager.save_calibration_set(calibration_name) saved = self.calibration_manager.save_calibration_set(calibration_name)
logger.info("Calibration set saved", name=calibration_name, preset=saved.preset.filename)
return saved
def get_available_calibrations(self, preset: ConfigPreset | None = None) -> List[str]: def get_available_calibrations(self, preset: ConfigPreset | None = None) -> list[str]:
"""Get available calibrations for current or specified preset""" """List available calibration set names for a preset (current if omitted)."""
preset = preset or self.get_current_preset()
if preset is None: if preset is None:
preset = self.get_current_preset() return []
if preset is None:
return []
return self.calibration_manager.get_available_calibrations(preset) return self.calibration_manager.get_available_calibrations(preset)
def set_current_calibration(self, calibration_name: str, preset: ConfigPreset | None = None): def set_current_calibration(self, calibration_name: str, preset: ConfigPreset | None = None) -> None:
"""Set current calibration""" """Activate a calibration set by updating the symlink."""
preset = preset or self.get_current_preset()
if preset is None: if preset is None:
preset = self.get_current_preset() raise RuntimeError("No current preset selected")
if preset is None:
raise RuntimeError("No current preset selected")
self.calibration_manager.set_current_calibration(preset, calibration_name) self.calibration_manager.set_current_calibration(preset, calibration_name)
logger.info("Current calibration set", name=calibration_name, preset=preset.filename)
def get_current_calibration(self) -> CalibrationSet | None: def get_current_calibration(self) -> CalibrationSet | None:
"""Get currently selected calibration set (saved and active via symlink)""" """
current_preset = self.get_current_preset() Return the active (saved and selected) calibration set for the current preset,
if current_preset is not None: or None if not set/invalid.
return self.calibration_manager.get_current_calibration(current_preset) """
else: current = self.get_current_preset()
if current is None:
return None return None
return self.calibration_manager.get_current_calibration(current)
def get_calibration_info(self, calibration_name: str, preset: ConfigPreset | None = None) -> Dict: def get_calibration_info(self, calibration_name: str, preset: ConfigPreset | None = None) -> dict[str, Any]:
"""Get calibration information""" """Return info/metadata for a specific calibration set."""
preset = preset or self.get_current_preset()
if preset is None: if preset is None:
preset = self.get_current_preset() raise RuntimeError("No current preset selected")
if preset is None:
raise RuntimeError("No current preset selected")
return self.calibration_manager.get_calibration_info(preset, calibration_name) return self.calibration_manager.get_calibration_info(preset, calibration_name)
# ---------- Combined Status and UI helpers ---------- @staticmethod
def get_required_standards(mode: VNAMode) -> list[CalibrationStandard]:
"""Return the list of required standards for a given VNA mode."""
if mode == VNAMode.S11:
return [CalibrationStandard.OPEN, CalibrationStandard.SHORT, CalibrationStandard.LOAD]
if mode == VNAMode.S21:
return [CalibrationStandard.THROUGH]
return []
def get_status_summary(self) -> Dict[str, object]: # ------------------------------------------------------------------ #
# Acquisition integration
# ------------------------------------------------------------------ #
def capture_calibration_standard_from_acquisition(
self,
standard: CalibrationStandard,
data_acquisition: VNADataAcquisition,
) -> int:
"""
Capture the latest sweep from acquisition as a calibration standard.
Returns
-------
int
The sweep number captured.
Raises
------
RuntimeError
If no sweep is available in the acquisition buffer.
"""
latest = data_acquisition.sweep_buffer.get_latest_sweep()
if latest is None:
raise RuntimeError("No sweep data available in acquisition buffer")
self.add_calibration_standard(standard, latest)
logger.info(
"Captured calibration standard from acquisition",
standard=standard.value,
sweep_number=latest.sweep_number,
)
return latest.sweep_number
def get_status_summary(self) -> dict[str, object]:
"""Get comprehensive status of current configuration and calibration""" """Get comprehensive status of current configuration and calibration"""
current_preset = self.get_current_preset() current_preset = self.get_current_preset()
current_calibration = self.get_current_calibration() current_calibration = self.get_current_calibration()
working_calibration = self.get_current_working_calibration() working_calibration = self.get_current_working_calibration()
logger.info(f"Settings status requested")
summary = { summary = {
"current_preset": None, "current_preset": None,
"current_calibration": None, "current_calibration": None,
@ -154,27 +224,3 @@ class VNASettingsManager:
} }
return summary return 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 []
# ---------- 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

@ -11,14 +11,14 @@ from vna_system.core.processors.storage.data_storage import DataStorage
from vna_system.core.settings.settings_manager import VNASettingsManager from vna_system.core.settings.settings_manager import VNASettingsManager
from vna_system.core.processors.manager import ProcessorManager from vna_system.core.processors.manager import ProcessorManager
from vna_system.core.processors.websocket_handler import ProcessorWebSocketHandler from vna_system.core.processors.websocket_handler import ProcessorWebSocketHandler
from vna_system.core.config import PROCESSORS_CONFIG_DIR_PATH
# Global singleton instances # Global singleton instances
vna_data_acquisition_instance: VNADataAcquisition = VNADataAcquisition() vna_data_acquisition_instance: VNADataAcquisition = VNADataAcquisition()
settings_manager: VNASettingsManager = VNASettingsManager() settings_manager: VNASettingsManager = VNASettingsManager()
# Processor system # Processor system
processor_config_dir = Path("vna_system/core/processors/configs") processor_manager: ProcessorManager = ProcessorManager(vna_data_acquisition_instance.sweep_buffer, settings_manager, Path(PROCESSORS_CONFIG_DIR_PATH))
processor_manager: ProcessorManager = ProcessorManager(vna_data_acquisition_instance.sweep_buffer, settings_manager, processor_config_dir)
data_storage = DataStorage() data_storage = DataStorage()
processor_websocket_handler: ProcessorWebSocketHandler = ProcessorWebSocketHandler( processor_websocket_handler: ProcessorWebSocketHandler = ProcessorWebSocketHandler(
processor_manager, data_storage processor_manager, data_storage

View File

@ -1,264 +1,292 @@
import numpy as np
from typing import Dict, Any, List, Tuple
import json
from pathlib import Path from pathlib import Path
import json
from typing import Any
import numpy as np
from vna_system.core.logging.logger import get_component_logger
from vna_system.core.acquisition.sweep_buffer import SweepData from vna_system.core.acquisition.sweep_buffer import SweepData
from vna_system.core.settings.preset_manager import ConfigPreset from vna_system.core.settings.preset_manager import ConfigPreset
logger = get_component_logger(__file__)
def generate_magnitude_plot_from_sweep_data(sweep_data: SweepData, preset: ConfigPreset = None) -> Dict[str, Any]:
# -----------------------------------------------------------------------------
# Plot builders
# -----------------------------------------------------------------------------
def generate_magnitude_plot_from_sweep_data(
sweep_data: SweepData,
preset: ConfigPreset | None = None,
) -> dict[str, Any]:
""" """
Generate Plotly configuration for magnitude plot from SweepData Build a Plotly configuration for magnitude-vs-frequency from a `SweepData`.
Args: Parameters
sweep_data: SweepData instance with points list of [real, imag] complex pairs ----------
preset: Optional ConfigPreset with frequency info sweep_data
Sweep payload with `.points: list[tuple[float, float]]` of (real, imag).
preset
Optional preset carrying frequency range (Hz). If absent, defaults are used.
Returns: Returns
Plotly configuration dict for magnitude plot -------
dict[str, Any]
Plotly figure spec. If input is invalid, returns {"error": "..."}.
""" """
if not sweep_data or not sweep_data.points: if not sweep_data or not sweep_data.points:
return {'error': 'Invalid sweep data'} logger.warning("Invalid sweep passed to magnitude plot")
return {"error": "Invalid sweep data"}
# Extract frequency range from preset or use defaults # Frequency range (Hz)
start_freq = 100e6 # 100 MHz start_freq = float(preset.start_freq) if (preset and preset.start_freq is not None) else 100e6
stop_freq = 8.8e9 # 8.8 GHz stop_freq = float(preset.stop_freq) if (preset and preset.stop_freq is not None) else 8.8e9
if preset: n = len(sweep_data.points)
start_freq = preset.start_freq or start_freq if n == 1:
stop_freq = preset.stop_freq or stop_freq freqs = [start_freq]
else:
step = (stop_freq - start_freq) / max(1, n - 1)
freqs = [start_freq + i * step for i in range(n)]
frequencies = [] # Magnitudes (dB). Clamp zero magnitude to -120 dB to avoid -inf.
magnitudes_db = [] mags_db: list[float] = []
for real, imag in sweep_data.points:
mag = abs(complex(real, imag))
mags_db.append(20.0 * np.log10(mag) if mag > 0.0 else -120.0)
# Calculate magnitude in dB for each point # Reasonable Y range with margin
for i, (real, imag) in enumerate(sweep_data.points): ymin = float(min(mags_db))
complex_val = complex(real, imag) ymax = float(max(mags_db))
magnitude_db = 20 * np.log10(abs(complex_val)) if abs(complex_val) > 0 else -120 ymargin = (ymax - ymin) * 0.1 if ymax > ymin else 10.0
y_min = max(ymin - ymargin, -120.0)
y_max = min(ymax + ymargin, 20.0)
# Calculate frequency based on point index
total_points = len(sweep_data.points)
frequency = start_freq + (stop_freq - start_freq) * i / (total_points - 1)
frequencies.append(frequency)
magnitudes_db.append(magnitude_db)
# Create Plotly trace
trace = { trace = {
'x': [f / 1e9 for f in frequencies], # Convert to GHz "x": [f / 1e9 for f in freqs], # Hz -> GHz
'y': magnitudes_db, "y": mags_db,
'type': 'scatter', "type": "scatter",
'mode': 'lines', "mode": "lines",
'name': 'Magnitude', "name": "Magnitude",
'line': {'color': '#1f77b4', 'width': 2} "line": {"color": "#1f77b4", "width": 2},
} }
# Calculate reasonable Y-axis range fig = {
min_mag = min(magnitudes_db) "data": [trace],
max_mag = max(magnitudes_db) "layout": {
y_margin = (max_mag - min_mag) * 0.1 "title": "Magnitude Response",
y_min = max(min_mag - y_margin, -120) "xaxis": {
y_max = min(max_mag + y_margin, 20) "title": "Frequency (GHz)",
"showgrid": True,
return { "gridcolor": "#e5e5e5",
'data': [trace], "gridwidth": 1,
'layout': {
'title': 'Magnitude Response',
'xaxis': {
'title': 'Frequency (GHz)',
'showgrid': True,
'gridcolor': '#e5e5e5',
'gridwidth': 1
}, },
'yaxis': { "yaxis": {
'title': 'Magnitude (dB)', "title": "Magnitude (dB)",
'range': [y_min, y_max], "range": [y_min, y_max],
'showgrid': True, "showgrid": True,
'gridcolor': '#e5e5e5', "gridcolor": "#e5e5e5",
'gridwidth': 1 "gridwidth": 1,
}, },
'plot_bgcolor': '#fafafa', "plot_bgcolor": "#fafafa",
'paper_bgcolor': '#ffffff', "paper_bgcolor": "#ffffff",
'font': { "font": {"family": "Arial, sans-serif", "size": 12, "color": "#333333"},
'family': 'Arial, sans-serif', "hovermode": "x unified",
'size': 12, "showlegend": True,
'color': '#333333' "margin": {"l": 60, "r": 40, "t": 60, "b": 60},
}, },
'hovermode': 'x unified',
'showlegend': True,
'margin': {'l': 60, 'r': 40, 't': 60, 'b': 60}
}
} }
logger.debug("Magnitude plot generated", points=n)
return fig
# -----------------------------------------------------------------------------
# IO helpers
# -----------------------------------------------------------------------------
def load_sweep_data_from_json(json_file: Path) -> SweepData: def load_sweep_data_from_json(json_file: Path) -> SweepData:
""" """
Load SweepData from JSON file Load a `SweepData` structure from a JSON file.
Args: The file is expected to contain:
json_file: Path to JSON file containing sweep data { "sweep_number": int, "timestamp": float, "points": [[r, i], ...], "total_points": int }
Returns: If `total_points` is missing, it is derived from the length of `points`.
SweepData instance
""" """
with open(json_file, 'r') as f: data = json.loads(Path(json_file).read_text(encoding="utf-8"))
data = json.load(f)
return SweepData( points = data.get("points", [])
sweep_number=data.get('sweep_number', 0), if not isinstance(points, list):
timestamp=data.get('timestamp', 0.0), raise ValueError(f"Invalid 'points' in file: {json_file}")
points=data.get('points', []),
total_points=data.get('total_points', len(data.get('points', []))) # Normalize to list[tuple[float, float]]
norm_points: list[tuple[float, float]] = []
for pt in points:
if not (isinstance(pt, (list, tuple)) and len(pt) == 2):
raise ValueError(f"Invalid point format in {json_file}: {pt!r}")
r, i = pt
norm_points.append((float(r), float(i)))
sweep = SweepData(
sweep_number=int(data.get("sweep_number", 0)),
timestamp=float(data.get("timestamp", 0.0)),
points=norm_points,
total_points=int(data.get("total_points", len(norm_points))),
) )
return sweep
def generate_standards_magnitude_plots(calibration_path: Path, preset: ConfigPreset = None) -> Dict[str, Any]: # -----------------------------------------------------------------------------
# Calibration plots
# -----------------------------------------------------------------------------
def generate_standards_magnitude_plots(
calibration_path: Path,
preset: ConfigPreset | None = None,
) -> dict[str, Any]:
""" """
Generate magnitude plots for all calibration standards in a calibration set Build individual magnitude plots for all calibration standards found under a folder.
Args: The function scans `calibration_path` for `*.json` (ignoring `*metadata.json`), loads each
calibration_path: Path to calibration directory sweep, and produces a Plotly config per standard. Raw sweep data and (optional) frequency
preset: Optional ConfigPreset info are embedded into the output for convenience.
Returns: Returns
Dictionary with plots for each standard, including raw data -------
dict[str, Any]
{ "<standard>": <plotly fig | {'error': str}>, ... }
""" """
plots = {} plots: dict[str, Any] = {}
standard_colors = { standard_colors = {
'open': '#2ca02c', # Green "open": "#2ca02c", # Green
'short': '#d62728', # Red "short": "#d62728", # Red
'load': '#ff7f0e', # Orange "load": "#ff7f0e", # Orange
'through': '#1f77b4' # Blue "through": "#1f77b4", # Blue
} }
# Find all standard JSON files for standard_file in Path(calibration_path).glob("*.json"):
for standard_file in calibration_path.glob('*.json'):
standard_name = standard_file.stem standard_name = standard_file.stem
if "metadata" in standard_name:
# Skip metadata files
if 'metadata' in standard_name:
continue continue
try: try:
sweep_data = load_sweep_data_from_json(standard_file) sweep = load_sweep_data_from_json(standard_file)
plot_config = generate_magnitude_plot_from_sweep_data(sweep_data, preset) fig = generate_magnitude_plot_from_sweep_data(sweep, preset)
if 'error' not in plot_config: if "error" in fig:
# Customize color and title for this standard plots[standard_name] = fig
if plot_config.get('data'): continue
plot_config['data'][0]['line']['color'] = standard_colors.get(standard_name, '#1f77b4')
plot_config['data'][0]['name'] = f'{standard_name.upper()} Standard'
plot_config['layout']['title'] = f'{standard_name.upper()} Standard Magnitude'
# Include raw sweep data for download # Customize per-standard appearance/title
plot_config['raw_sweep_data'] = { if fig.get("data"):
'sweep_number': sweep_data.sweep_number, fig["data"][0]["line"]["color"] = standard_colors.get(standard_name, "#1f77b4")
'timestamp': sweep_data.timestamp, fig["data"][0]["name"] = f"{standard_name.upper()} Standard"
'total_points': sweep_data.total_points, fig["layout"]["title"] = f"{standard_name.upper()} Standard Magnitude"
'points': sweep_data.points, # Raw complex data points
'file_path': str(standard_file) # Attach raw sweep block for UI download/inspection
fig["raw_sweep_data"] = {
"sweep_number": sweep.sweep_number,
"timestamp": sweep.timestamp,
"total_points": sweep.total_points,
"points": sweep.points,
"file_path": str(standard_file),
}
# Optional frequency info
if preset:
fig["frequency_info"] = {
"start_freq": preset.start_freq,
"stop_freq": preset.stop_freq,
"points": preset.points,
"bandwidth": preset.bandwidth,
} }
# Add frequency information if available plots[standard_name] = fig
if preset:
plot_config['frequency_info'] = {
'start_freq': preset.start_freq,
'stop_freq': preset.stop_freq,
'points': preset.points,
'bandwidth': preset.bandwidth
}
plots[standard_name] = plot_config except (json.JSONDecodeError, FileNotFoundError, KeyError, ValueError) as exc:
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e: logger.warning("Failed to load standard plot", file=str(standard_file), error=repr(exc))
plots[standard_name] = {'error': f'Failed to load {standard_name}: {str(e)}'} plots[standard_name] = {"error": f"Failed to load {standard_name}: {exc}"}
return plots return plots
def generate_combined_standards_plot(calibration_path: Path, preset: ConfigPreset = None) -> Dict[str, Any]: def generate_combined_standards_plot(
calibration_path: Path,
preset: ConfigPreset | None = None,
) -> dict[str, Any]:
""" """
Generate a combined plot showing all calibration standards Build a combined Plotly figure that overlays all available calibration standards.
Args: Each standard is rendered as a separate trace with a canonical color.
calibration_path: Path to calibration directory
preset: Optional ConfigPreset
Returns:
Plotly configuration dict with all standards overlaid
""" """
traces = [] traces: list[dict[str, Any]] = []
standard_colors = { standard_colors = {
'open': '#2ca02c', # Green "open": "#2ca02c", # Green
'short': '#d62728', # Red "short": "#d62728", # Red
'load': '#ff7f0e', # Orange "load": "#ff7f0e", # Orange
'through': '#1f77b4' # Blue "through": "#1f77b4", # Blue
} }
y_min, y_max = 0, -120 # Initialize Y range trackers inversely so first update sets them correctly.
y_min, y_max = 0.0, -120.0
# Process each standard for standard_file in Path(calibration_path).glob("*.json"):
for standard_file in calibration_path.glob('*.json'): name = standard_file.stem
standard_name = standard_file.stem if "metadata" in name:
# Skip metadata files
if 'metadata' in standard_name:
continue continue
try: try:
sweep_data = load_sweep_data_from_json(standard_file) sweep = load_sweep_data_from_json(standard_file)
plot_config = generate_magnitude_plot_from_sweep_data(sweep_data, preset) fig = generate_magnitude_plot_from_sweep_data(sweep, preset)
if "error" in fig or not fig.get("data"):
continue
if 'error' not in plot_config and plot_config.get('data'): trace = dict(fig["data"][0]) # shallow copy
trace = plot_config['data'][0].copy() trace["line"]["color"] = standard_colors.get(name, "#1f77b4")
trace['line']['color'] = standard_colors.get(standard_name, '#1f77b4') trace["name"] = f"{name.upper()} Standard"
trace['name'] = f'{standard_name.upper()} Standard' traces.append(trace)
traces.append(trace)
# Update Y range # Update Y range
if trace['y']: y_vals = trace.get("y") or []
trace_min = min(trace['y']) if y_vals:
trace_max = max(trace['y']) tmin = float(min(y_vals))
y_min = min(y_min, trace_min) tmax = float(max(y_vals))
y_max = max(y_max, trace_max) y_min = min(y_min, tmin)
y_max = max(y_max, tmax)
except (json.JSONDecodeError, FileNotFoundError, KeyError): except (json.JSONDecodeError, FileNotFoundError, KeyError, ValueError) as exc:
logger.warning("Failed to include standard in combined plot", file=str(standard_file), error=repr(exc))
continue continue
if not traces: if not traces:
return {'error': 'No valid calibration standards found'} return {"error": "No valid calibration standards found"}
# Add margin to Y range ymargin = (y_max - y_min) * 0.1 if y_max > y_min else 10.0
y_margin = (y_max - y_min) * 0.1 y_min = max(y_min - ymargin, -120.0)
y_min = max(y_min - y_margin, -120) y_max = min(y_max + ymargin, 20.0)
y_max = min(y_max + y_margin, 20)
return { fig = {
'data': traces, "data": traces,
'layout': { "layout": {
'title': 'Calibration Standards Comparison', "title": "Calibration Standards Comparison",
'xaxis': { "xaxis": {
'title': 'Frequency (GHz)', "title": "Frequency (GHz)",
'showgrid': True, "showgrid": True,
'gridcolor': '#e5e5e5', "gridcolor": "#e5e5e5",
'gridwidth': 1 "gridwidth": 1,
}, },
'yaxis': { "yaxis": {
'title': 'Magnitude (dB)', "title": "Magnitude (dB)",
'range': [y_min, y_max], "range": [y_min, y_max],
'showgrid': True, "showgrid": True,
'gridcolor': '#e5e5e5', "gridcolor": "#e5e5e5",
'gridwidth': 1 "gridwidth": 1,
}, },
'plot_bgcolor': '#fafafa', "plot_bgcolor": "#fafafa",
'paper_bgcolor': '#ffffff', "paper_bgcolor": "#ffffff",
'font': { "font": {"family": "Arial, sans-serif", "size": 12, "color": "#333333"},
'family': 'Arial, sans-serif', "hovermode": "x unified",
'size': 12, "showlegend": True,
'color': '#333333' "margin": {"l": 60, "r": 40, "t": 60, "b": 60},
}, },
'hovermode': 'x unified',
'showlegend': True,
'margin': {'l': 60, 'r': 40, 't': 60, 'b': 60}
}
} }
logger.debug("Combined standards plot generated", traces=len(traces))
return fig

106
vna_system/main.py Normal file
View File

@ -0,0 +1,106 @@
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
import vna_system.core.singletons as singletons
from vna_system.api.endpoints import acquisition, health, settings, web_ui
from vna_system.api.websockets import processing as ws_processing
from vna_system.core.config import API_HOST, API_PORT
from vna_system.core.logging.logger import get_component_logger, setup_logging
PROJECT_ROOT = Path(__file__).resolve().parents[1]
LOG_DIR = PROJECT_ROOT / "logs"
setup_logging(log_level=os.getenv("VNA_LOG_LEVEL", "INFO"), log_dir=LOG_DIR)
for noisy in (
"uvicorn.error",
"uvicorn.access",
):
logging.getLogger(noisy).setLevel(logging.ERROR)
logger = get_component_logger(__file__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Starting VNA API Server")
try:
logger.info("Starting data acquisition")
singletons.vna_data_acquisition_instance.start()
logger.info("Starting processor system")
singletons.processor_manager.start_processing()
logger.info(
"Processor system started",
processors=singletons.processor_manager.list_processors(),
)
logger.info("VNA API Server started successfully")
yield
except Exception as exc:
logger.error("Error during startup", error=repr(exc))
raise
logger.info("Shutting down VNA API Server")
if singletons.processor_manager:
singletons.processor_manager.stop_processing()
logger.info("Processor system stopped")
if getattr(singletons, "vna_data_acquisition_instance", None) and singletons.vna_data_acquisition_instance._running:
singletons.vna_data_acquisition_instance.stop()
logger.info("Acquisition stopped")
logger.info("VNA API Server shutdown complete")
app = FastAPI(
title="VNA System API",
description="Real-time VNA data acquisition and processing API",
version="1.0.0",
lifespan=lifespan,
)
WEB_UI_DIR = Path(__file__).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("Mounted static files", directory=str(STATIC_DIR))
else:
logger.warning("Static directory not found", directory=str(STATIC_DIR))
app.include_router(web_ui.router)
app.include_router(health.router)
app.include_router(acquisition.router)
app.include_router(settings.router)
app.include_router(ws_processing.router)
def main() -> None:
host = os.getenv("VNA_HOST", API_HOST)
port_env = os.getenv("VNA_PORT")
if port_env is not None:
try:
port = int(port_env)
except ValueError:
logger.warning("Invalid VNA_PORT, falling back to config", VNA_PORT=port_env)
port = API_PORT
else:
port = API_PORT
logger.info("Launching Uvicorn", host=host, port=port)
uvicorn.run(
"vna_system.main:app",
host=host,
port=port,
log_level="info",
reload=False,
)
if __name__ == "__main__":
main()

View File

@ -36,7 +36,7 @@ log_error() {
} }
# Check if we're in the right directory # Check if we're in the right directory
if [ ! -f "$PROJECT_ROOT/vna_system/api/main.py" ]; then if [ ! -f "$PROJECT_ROOT/vna_system/main.py" ]; then
log_error "VNA System main.py not found. Please run this script from the project directory." log_error "VNA System main.py not found. Please run this script from the project directory."
exit 1 exit 1
fi fi
@ -89,6 +89,6 @@ log_info "Press Ctrl+C to stop the server"
echo echo
# Run the main application # Run the main application
exec python3 -m vna_system.api.main exec python3 -m vna_system.main