improved codestyle and added logging
This commit is contained in:
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"server": {
|
|
||||||
"host": "0.0.0.0",
|
|
||||||
"port": 8000
|
|
||||||
},
|
|
||||||
"logging": {
|
|
||||||
"level": "INFO"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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))
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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()
|
|
||||||
@ -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
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"preset": {
|
||||||
|
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
|
||||||
|
"mode": "s11",
|
||||||
|
"start_freq": 100000000.0,
|
||||||
|
"stop_freq": 8800000000.0,
|
||||||
|
"points": 1000,
|
||||||
|
"bandwidth": 1000.0
|
||||||
|
},
|
||||||
|
"calibration_name": "еуыеуые",
|
||||||
|
"standard": "load",
|
||||||
|
"sweep_number": 12,
|
||||||
|
"sweep_timestamp": 1758896376.33808,
|
||||||
|
"created_timestamp": "2025-09-26T17:19:50.017201",
|
||||||
|
"total_points": 1000
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"preset": {
|
||||||
|
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
|
||||||
|
"mode": "s11",
|
||||||
|
"start_freq": 100000000.0,
|
||||||
|
"stop_freq": 8800000000.0,
|
||||||
|
"points": 1000,
|
||||||
|
"bandwidth": 1000.0
|
||||||
|
},
|
||||||
|
"calibration_name": "еуыеуые",
|
||||||
|
"standard": "open",
|
||||||
|
"sweep_number": 10,
|
||||||
|
"sweep_timestamp": 1758896372.20023,
|
||||||
|
"created_timestamp": "2025-09-26T17:19:50.015286",
|
||||||
|
"total_points": 1000
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"preset": {
|
||||||
|
"filename": "s11_start100_stop8800_points1000_bw1khz.bin",
|
||||||
|
"mode": "s11",
|
||||||
|
"start_freq": 100000000.0,
|
||||||
|
"stop_freq": 8800000000.0,
|
||||||
|
"points": 1000,
|
||||||
|
"bandwidth": 1000.0
|
||||||
|
},
|
||||||
|
"calibration_name": "еуыеуые",
|
||||||
|
"standard": "short",
|
||||||
|
"sweep_number": 13,
|
||||||
|
"sweep_timestamp": 1758896378.4093437,
|
||||||
|
"created_timestamp": "2025-09-26T17:19:50.019159",
|
||||||
|
"total_points": 1000
|
||||||
|
}
|
||||||
@ -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
@ -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
@ -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
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
136
vna_system/core/acquisition/port_manager.py
Normal file
136
vna_system/core/acquisition/port_manager.py
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
|
||||||
9
vna_system/core/logging/__init__.py
Normal file
9
vna_system/core/logging/__init__.py
Normal 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']
|
||||||
257
vna_system/core/logging/logger.py
Normal file
257
vna_system/core/logging/logger.py
Normal 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)
|
||||||
@ -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 processor’s 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,
|
||||||
|
}
|
||||||
@ -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 (Open–Short–Load) 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
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
106
vna_system/main.py
Normal 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()
|
||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user