diff --git a/vna_system/api/endpoints/settings.py b/vna_system/api/endpoints/settings.py index 8e8cf38..440a418 100644 --- a/vna_system/api/endpoints/settings.py +++ b/vna_system/api/endpoints/settings.py @@ -19,6 +19,9 @@ from vna_system.api.models.settings import ( SetCalibrationRequest, RemoveStandardRequest, WorkingCalibrationModel, + ReferenceModel, + CreateReferenceRequest, + SetReferenceRequest, ) router = APIRouter(prefix="/api/v1/settings", tags=["settings"]) @@ -36,19 +39,10 @@ async def get_status() -> dict[str, Any]: @router.get("/presets", response_model=List[PresetModel]) -async def get_presets(mode: str | None = None) -> list[PresetModel]: +async def get_presets() -> list[PresetModel]: """Get all available configuration presets, optionally filtered by mode.""" try: - if mode: - from vna_system.core.settings.preset_manager import VNAMode - - try: - vna_mode = VNAMode(mode.lower()) - except ValueError: - raise HTTPException(status_code=400, detail=f"Invalid mode: {mode}") - presets = singletons.settings_manager.get_presets_by_mode(vna_mode) - else: - presets = singletons.settings_manager.get_available_presets() + presets = singletons.settings_manager.get_available_presets() return [ PresetModel( @@ -61,8 +55,6 @@ async def get_presets(mode: str | None = None) -> list[PresetModel]: ) for p in presets ] - except HTTPException: - raise except Exception as exc: # noqa: BLE001 logger.error("Failed to list presets") raise HTTPException(status_code=500, detail=str(exc)) @@ -415,3 +407,217 @@ async def get_working_calibration_standards_plots() -> dict[str, Any]: except Exception as exc: # noqa: BLE001 logger.error("Failed to build working calibration standards plots") raise HTTPException(status_code=500, detail=str(exc)) + + +# ================================================================ # +# Reference Management Endpoints +# ================================================================ # + +@router.get("/references", response_model=List[ReferenceModel]) +async def get_references(preset_filename: str | None = None) -> List[dict[str, Any]]: + """Get available references for a preset.""" + try: + settings_manager = singletons.settings_manager + + if preset_filename: + preset = next( + (p for p in settings_manager.get_available_presets() if p.filename == preset_filename), + None + ) + if not preset: + raise HTTPException(status_code=404, detail=f"Preset '{preset_filename}' not found") + else: + preset = settings_manager.get_current_preset() + if not preset: + raise HTTPException(status_code=400, detail="No current preset set and no preset_filename provided") + + references = settings_manager.get_available_references(preset) + + return [ + { + "name": ref.name, + "timestamp": ref.timestamp.isoformat(), + "preset_filename": ref.preset_filename, + "description": ref.description, + "metadata": ref.metadata + } + for ref in references + ] + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + logger.error("Failed to get references", error=repr(exc)) + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.get("/reference/current", response_model=ReferenceModel | None) +async def get_current_reference(preset_filename: str | None = None) -> dict[str, Any] | None: + """Get the currently selected reference.""" + try: + settings_manager = singletons.settings_manager + + if preset_filename: + preset = next( + (p for p in settings_manager.get_available_presets() if p.filename == preset_filename), + None + ) + if not preset: + raise HTTPException(status_code=404, detail=f"Preset '{preset_filename}' not found") + else: + preset = settings_manager.get_current_preset() + if not preset: + raise HTTPException(status_code=400, detail="No current preset set and no preset_filename provided") + + current_ref = settings_manager.get_current_reference(preset) + + if not current_ref: + return None + + return { + "name": current_ref.name, + "timestamp": current_ref.timestamp.isoformat(), + "preset_filename": current_ref.preset_filename, + "description": current_ref.description, + "metadata": current_ref.metadata + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + logger.error("Failed to get current reference", error=repr(exc)) + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.post("/reference/create") +async def create_reference(request: CreateReferenceRequest) -> dict[str, Any]: + """Create a new reference from current sweep data.""" + try: + settings_manager = singletons.settings_manager + data_acquisition = singletons.vna_data_acquisition_instance + + if request.preset_filename: + preset = next( + (p for p in settings_manager.get_available_presets() if p.filename == request.preset_filename), + None + ) + if not preset: + raise HTTPException(status_code=404, detail=f"Preset '{request.preset_filename}' not found") + else: + preset = settings_manager.get_current_preset() + if not preset: + raise HTTPException(status_code=400, detail="No current preset set and no preset_filename provided") + + # Create reference using the new capture method + reference_info = settings_manager.capture_reference_from_acquisition( + reference_name=request.name, + data_acquisition=data_acquisition, + description=request.description, + metadata=request.metadata, + preset=preset + ) + + return { + "success": True, + "message": f"Reference '{request.name}' created successfully", + "reference": { + "name": reference_info.name, + "timestamp": reference_info.timestamp.isoformat(), + "preset_filename": reference_info.preset_filename, + "description": reference_info.description, + "metadata": reference_info.metadata + } + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + logger.error("Failed to create reference", name=request.name, error=repr(exc)) + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.post("/reference/set") +async def set_current_reference(request: SetReferenceRequest) -> dict[str, Any]: + """Set the current reference.""" + try: + settings_manager = singletons.settings_manager + + if request.preset_filename: + preset = next( + (p for p in settings_manager.get_available_presets() if p.filename == request.preset_filename), + None + ) + if not preset: + raise HTTPException(status_code=404, detail=f"Preset '{request.preset_filename}' not found") + else: + preset = settings_manager.get_current_preset() + if not preset: + raise HTTPException(status_code=400, detail="No current preset set and no preset_filename provided") + + success = settings_manager.set_current_reference(request.name, preset) + + if not success: + raise HTTPException(status_code=404, detail=f"Reference '{request.name}' not found") + + return { + "success": True, + "message": f"Reference '{request.name}' set as current" + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + logger.error("Failed to set current reference", name=request.name, error=repr(exc)) + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.delete("/reference/current") +async def clear_current_reference() -> dict[str, Any]: + """Clear the current reference selection.""" + try: + settings_manager = singletons.settings_manager + success = settings_manager.clear_current_reference() + + if not success: + raise HTTPException(status_code=500, detail="Failed to clear current reference") + + return { + "success": True, + "message": "Current reference cleared" + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + logger.error("Failed to clear current reference", error=repr(exc)) + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.delete("/reference/{reference_name}") +async def delete_reference(reference_name: str, preset_filename: str | None = None) -> dict[str, Any]: + """Delete a reference.""" + try: + settings_manager = singletons.settings_manager + + if preset_filename: + preset = next( + (p for p in settings_manager.get_available_presets() if p.filename == preset_filename), + None + ) + if not preset: + raise HTTPException(status_code=404, detail=f"Preset '{preset_filename}' not found") + else: + preset = settings_manager.get_current_preset() + if not preset: + raise HTTPException(status_code=400, detail="No current preset set and no preset_filename provided") + + success = settings_manager.delete_reference(reference_name, preset) + + if not success: + raise HTTPException(status_code=404, detail=f"Reference '{reference_name}' not found") + + return { + "success": True, + "message": f"Reference '{reference_name}' deleted successfully" + } + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + logger.error("Failed to delete reference", name=reference_name, error=repr(exc)) + raise HTTPException(status_code=500, detail=str(exc)) + diff --git a/vna_system/api/models/settings.py b/vna_system/api/models/settings.py index 56b5d50..2053c81 100644 --- a/vna_system/api/models/settings.py +++ b/vna_system/api/models/settings.py @@ -56,4 +56,25 @@ class WorkingCalibrationModel(BaseModel): progress: str | None = None is_complete: bool | None = None completed_standards: List[str] | None = None - missing_standards: List[str] | None = None \ No newline at end of file + missing_standards: List[str] | None = None + + +# Reference models +class ReferenceModel(BaseModel): + name: str + timestamp: str + preset_filename: str + description: str + metadata: Dict[str, Any] + + +class CreateReferenceRequest(BaseModel): + name: str + description: str = "" + metadata: Dict[str, Any] = {} + preset_filename: str | None = None + + +class SetReferenceRequest(BaseModel): + name: str + preset_filename: str | None = None \ No newline at end of file diff --git a/vna_system/binary_input/current_input.bin b/vna_system/binary_input/current_input.bin index e701777..7f4ad93 120000 --- a/vna_system/binary_input/current_input.bin +++ b/vna_system/binary_input/current_input.bin @@ -1 +1 @@ -config_inputs/s21_start100_stop8800_points1000_bw1khz.bin \ No newline at end of file +config_inputs/s11_start100_stop8800_points1000_bw1khz.bin \ No newline at end of file diff --git a/vna_system/calibration/current_calibration b/vna_system/calibration/current_calibration deleted file mode 120000 index 635f973..0000000 --- a/vna_system/calibration/current_calibration +++ /dev/null @@ -1 +0,0 @@ -s21_start100_stop8800_points1000_bw1khz/bambambum \ No newline at end of file diff --git a/vna_system/core/acquisition/data_acquisition.py b/vna_system/core/acquisition/data_acquisition.py index cf440e2..f373b25 100644 --- a/vna_system/core/acquisition/data_acquisition.py +++ b/vna_system/core/acquisition/data_acquisition.py @@ -75,7 +75,7 @@ class VNADataAcquisition: @property def is_running(self) -> bool: """Return True if the acquisition thread is alive.""" - return self._running and (self._thread is not None and self._thread.is_alive()) + return self._running and (self._thread is not None and self._thread.is_alive()) and not self._paused @property def sweep_buffer(self) -> SweepBuffer: diff --git a/vna_system/core/processors/base_processor.py b/vna_system/core/processors/base_processor.py index 7a2e9a6..9842cac 100644 --- a/vna_system/core/processors/base_processor.py +++ b/vna_system/core/processors/base_processor.py @@ -299,7 +299,7 @@ class BaseProcessor: # --------------------------------------------------------------------- # # Data path: accept new sweep, recompute, produce result # --------------------------------------------------------------------- # - def add_sweep_data(self, sweep_data: Any, calibrated_data: Any, vna_config: ConfigPreset | None): + def add_sweep_data(self, sweep_data: Any, calibrated_data: Any, vna_config: ConfigPreset | None, reference_data: Any = None): """ Add the latest sweep to the in-memory history and trigger recalculation. @@ -311,6 +311,8 @@ class BaseProcessor: Data post-calibration (structure is processor-specific). vna_config: Snapshot of VNA settings (dataclass or pydantic model supported). + reference_data: + Open air reference sweep data for background subtraction/normalization. Returns ------- @@ -323,6 +325,7 @@ class BaseProcessor: "sweep_data": sweep_data, "calibrated_data": calibrated_data, "vna_config": asdict(vna_config) if vna_config is not None else {}, + "reference_data": reference_data, "timestamp": datetime.now().timestamp(), } ) diff --git a/vna_system/core/processors/configs/bscan_config.json b/vna_system/core/processors/configs/bscan_config.json new file mode 100644 index 0000000..15d18c4 --- /dev/null +++ b/vna_system/core/processors/configs/bscan_config.json @@ -0,0 +1,12 @@ +{ + "open_air": true, + "axis": "real", + "data_limitation": null, + "cut": 0.824, + "max": 1.1, + "gain": 1.2, + "start_freq": 100.0, + "stop_freq": 8800.0, + "clear_history": false, + "data_limit": 500 +} \ No newline at end of file diff --git a/vna_system/core/processors/implementations/bscan_processor.py b/vna_system/core/processors/implementations/bscan_processor.py new file mode 100644 index 0000000..638ee04 --- /dev/null +++ b/vna_system/core/processors/implementations/bscan_processor.py @@ -0,0 +1,575 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Final + +import numpy as np +from numpy.typing import NDArray + +from vna_system.core.logging.logger import get_component_logger +from vna_system.core.processors.base_processor import BaseProcessor, UIParameter +from vna_system.core.acquisition.sweep_buffer import SweepData + +logger = get_component_logger(__file__) + + +class BScanProcessor(BaseProcessor): + """ + B-Scan processor for time-domain analysis of VNA measurements. + + Features + ------- + - Optional open-air reference subtraction + - Configurable axis: "real", "abs", "phase" + - IFFT with frequency range filtering + - Depth windowing with gain shaping + - Plot history accumulation for multi-sweep heatmaps + """ + + # Physical and display-related constants + SPEED_OF_LIGHT_M_S: Final[float] = 299_700_000.0 # m/s + + def __init__(self, config_dir: Path) -> None: + super().__init__("bscan", config_dir) + + # Increase history size for multi-sweep plotting + self.max_history = 10 + + # Local plot history (separate from sweep history maintained by BaseProcessor) + self._plot_history: list[dict[str, Any]] = [] + self._max_plot_history = 50 + + logger.info("BScanProcessor initialized", processor_id=self.processor_id) + + # ------------------------------------------------------------------------- + # Configuration + # ------------------------------------------------------------------------- + + def _get_default_config(self) -> dict[str, Any]: + """Return default configuration values.""" + return { + "open_air": False, # Toggle for reference usage + "axis": "abs", # "real", "abs", or "phase" + "data_limitation": None, # None, "ph_only_1", "ph_only_2" + "cut": 0.824, # Cut parameter (meters) + "max": 1.0, # Max depth (meters) + "gain": 1.0, # Gain exponent + "start_freq": 100.0, # Start frequency (MHz) + "stop_freq": 8800.0, # Stop frequency (MHz) + "clear_history": False, # UI button; not persisted + } + + def get_ui_parameters(self) -> list[UIParameter]: + """Return UI parameter schema for configuration.""" + cfg = self._config # Local alias for brevity + + return [ + UIParameter( + name="open_air", + label="Open Air Reference", + type="toggle", + value=cfg["open_air"], + ), + UIParameter( + name="axis", + label="Axis", + type="select", + value=cfg["axis"], + options={"choices": ["real", "abs", "phase"]}, + ), + UIParameter( + name="data_limitation", + label="Data Limitation", + type="select", + value=cfg["data_limitation"], + options={"choices": [None, "ph_only_1", "ph_only_2"]}, + ), + UIParameter( + name="cut", + label="Cut (m)", + type="slider", + value=cfg["cut"], + options={"min": 0.0, "max": 2.0, "step": 0.001, "dtype": "float"}, + ), + UIParameter( + name="max", + label="Max Depth (m)", + type="slider", + value=cfg["max"], + options={"min": 0.1, "max": 5.0, "step": 0.1, "dtype": "float"}, + ), + UIParameter( + name="gain", + label="Gain Factor", + type="slider", + value=cfg["gain"], + options={"min": 0.0, "max": 3.0, "step": 0.1, "dtype": "float"}, + ), + UIParameter( + name="start_freq", + label="Start Frequency (MHz)", + type="slider", + value=cfg["start_freq"], + options={"min": 100.0, "max": 8800.0, "step": 10.0, "dtype": "float"}, + ), + UIParameter( + name="stop_freq", + label="Stop Frequency (MHz)", + type="slider", + value=cfg["stop_freq"], + options={"min": 100.0, "max": 8800.0, "step": 10.0, "dtype": "float"}, + ), + UIParameter( + name="clear_history", + label="Clear Plot History", + type="button", + value=False, + options={"action": "Clear accumulated plot history"}, + ), + ] + + def update_config(self, updates: dict[str, Any]) -> None: + """Handle config updates, including the 'clear history' button action.""" + if updates.get("clear_history"): + self._clear_plot_history() + # Remove button flag before delegating to base class + updates = {k: v for k, v in updates.items() if k != "clear_history"} + + if updates: + super().update_config(updates) + + def _clear_plot_history(self) -> None: + """Clear the accumulated plot history.""" + with self._lock: + self._plot_history.clear() + logger.info("Plot history cleared", processor_id=self.processor_id) + + # ------------------------------------------------------------------------- + # Processing + # ------------------------------------------------------------------------- + + def process_sweep( + self, + sweep_data: SweepData, + calibrated_data: SweepData | None, + vna_config: dict[str, Any], + ) -> dict[str, Any]: + """ + Process a single sweep and prepare B-scan data. + + Returns + ------- + dict + Keys: time_domain_data, distance_data, frequency_range, reference_used, + axis_type, data_limitation, points_processed, plot_history_count + Or: {"error": "..."} on failure. + """ + try: + # Choose calibrated data when provided + data_to_process = calibrated_data or sweep_data + + complex_data = self._get_complex_s11(data_to_process) + if complex_data is None or complex_data.size == 0: + logger.warning("No valid complex data for B-scan processing") + return {"error": "No valid complex data"} + + # Optional reference subtraction (latest stored reference in sweep history) + reference_data: SweepData | None = None + if self._config["open_air"] and self._sweep_history: + latest_history = self._sweep_history[-1] + reference_data = latest_history.get("reference_data") + + if self._config["open_air"] and reference_data is not None: + reference_complex = self._get_complex_s11(reference_data) + if reference_complex is not None and reference_complex.size: + complex_data = self._subtract_reference(complex_data, reference_complex) + logger.debug("Applied open-air reference subtraction") + + # Keep frequency controls in sync with the current VNA config + self._update_frequency_ranges(vna_config) + + # Perform the main analysis pipeline + analysis = self._perform_data_analysis(complex_data, vna_config) + if analysis is None: + logger.warning("Data analysis failed") + return {"error": "Data analysis failed"} + + plot_record = { + "time_domain_data": analysis["time_data"].tolist(), + "distance_data": analysis["distance"].tolist(), + "sweep_number": sweep_data.sweep_number, + "timestamp": sweep_data.timestamp, + "frequency_range": analysis["freq_range"], + } + + with self._lock: + self._plot_history.append(plot_record) + if len(self._plot_history) > self._max_plot_history: + self._plot_history = self._plot_history[-self._max_plot_history :] + + return { + "time_domain_data": analysis["time_data"].tolist(), + "distance_data": analysis["distance"].tolist(), + "frequency_range": analysis["freq_range"], + "reference_used": bool(self._config["open_air"] and reference_data is not None), + "axis_type": self._config["axis"], + "data_limitation": self._config["data_limitation"], + "points_processed": int(complex_data.size), + "plot_history_count": len(self._plot_history), + } + + except Exception as exc: # noqa: BLE001 + logger.error("B-scan processing failed", error=repr(exc)) + return {"error": str(exc)} + + # ------------------------------------------------------------------------- + # Visualization + # ------------------------------------------------------------------------- + + def generate_plotly_config( + self, + processed_data: dict[str, Any], + vna_config: dict[str, Any], # noqa: ARG002 - reserved for future layout tweaks + ) -> dict[str, Any]: + """ + Produce a Plotly-compatible heatmap configuration from accumulated sweeps. + """ + if "error" in processed_data: + return { + "data": [], + "layout": { + "title": "B-Scan Analysis - Error", + "annotations": [ + { + "text": f"Error: {processed_data['error']}", + "x": 0.5, + "y": 0.5, + "xref": "paper", + "yref": "paper", + "showarrow": False, + } + ], + "template": "plotly_dark", + }, + } + + with self._lock: + history = list(self._plot_history) + + if not history: + return { + "data": [], + "layout": { + "title": "B-Scan Analysis - No Data", + "xaxis": {"title": "Sweep Number"}, + "yaxis": {"title": "Depth (m)"}, + "template": "plotly_dark", + }, + } + + # Build scatter-like heatmap (irregular grid) from history + x_coords: list[int] = [] + y_coords: list[float] = [] + z_values: list[float] = [] + + for item in history: + sweep_num = item["sweep_number"] + depths = item["distance_data"] + amps = item["time_domain_data"] + + for d, a in zip(depths, amps, strict=False): + x_coords.append(sweep_num) + y_coords.append(d) + z_values.append(a) + + # Colorscale selection + if self._config["axis"] == "abs": + colorscale = "Viridis" + heatmap_kwargs: dict[str, Any] = {} + else: + colorscale = "RdBu" + heatmap_kwargs = {"zmid": 0} + + heatmap_trace = { + "type": "heatmap", + "x": x_coords, + "y": y_coords, + "z": z_values, + "colorscale": colorscale, + "colorbar": {"title": "Amplitude"}, + "hovertemplate": ( + "Sweep: %{x}
" + "Depth: %{y:.3f} m
" + "Amplitude: %{z:.3f}
" + "" + ), + **heatmap_kwargs, + } + + freq_start, freq_stop = processed_data.get("frequency_range", [0.0, 0.0]) + config_info = ( + f"Freq: {freq_start/1e6:.1f}-{freq_stop/1e6:.1f} MHz | " + f"Gain: {self._config['gain']:.1f} | " + f"Cut: {self._config['cut']:.3f} m | " + f"Max: {self._config['max']:.1f} m | " + f"Axis: {self._config['axis']} | " + f"Sweeps: {len(history)}" + ) + + if processed_data.get("reference_used", False): + config_info += " | Open Air: ON" + + if self._config["data_limitation"]: + config_info += f" | Limit: {self._config['data_limitation']}" + + layout = { + "title": f"B-Scan Heatmap - {config_info}", + "xaxis": {"title": "Sweep Number", "side": "top"}, + "yaxis": {"title": "Depth (m)", "autorange": "reversed"}, + "hovermode": "closest", + "height": 600, + "width": 500, + "template": "plotly_dark", + } + + return {"data": [heatmap_trace], "layout": layout} + + # ------------------------------------------------------------------------- + # Low-level helpers + # ------------------------------------------------------------------------- + + def _get_complex_s11(self, sweep_data: SweepData) -> NDArray[np.complex128] | None: + """Extract complex S11 array from sweep points.""" + try: + if not sweep_data.points: + return None + + # Each point is expected as a pair (real, imag) + arr = np.asarray(sweep_data.points, dtype=float) + if arr.ndim != 2 or arr.shape[1] != 2: + raise ValueError("Expected Nx2 array for (real, imag) points") + + complex_data = arr[:, 0] + 1j * arr[:, 1] + return complex_data.astype(np.complex128, copy=False) + + except Exception as exc: # noqa: BLE001 + logger.error("Failed to extract complex S11 data", error=repr(exc)) + return None + + def _subtract_reference( + self, + signal: NDArray[np.complex128], + reference: NDArray[np.complex128], + ) -> NDArray[np.complex128]: + """Subtract reference from signal (complex subtraction), length-safe.""" + try: + n = min(signal.size, reference.size) + if n == 0: + return signal + result = signal[:n] - reference[:n] + logger.debug("Reference subtraction completed", points=n) + return result + except Exception as exc: # noqa: BLE001 + logger.error("Reference subtraction failed", error=repr(exc)) + return signal # Non-fatal; continue with original signal + + def _update_frequency_ranges(self, vna_config: dict[str, Any]) -> None: + """Clamp configured frequency sliders to VNA limits.""" + if not vna_config: + return + + start_freq_hz = float(vna_config.get("start_freq", 100e6)) + stop_freq_hz = float(vna_config.get("stop_freq", 8.8e9)) + + start_mhz = start_freq_hz / 1e6 + stop_mhz = stop_freq_hz / 1e6 + + current_start = float(self._config["start_freq"]) + current_stop = float(self._config["stop_freq"]) + + # Ensure order and clamp to VNA bounds + new_start = max(start_mhz, min(stop_mhz, current_start)) + new_stop = max(new_start, min(stop_mhz, current_stop)) + + self._config["start_freq"] = new_start + self._config["stop_freq"] = new_stop + + def _perform_data_analysis( + self, + complex_data: NDArray[np.complex128], + vna_config: dict[str, Any], + ) -> dict[str, Any] | None: + """Full analysis pipeline: limit -> IFFT -> depth shaping.""" + try: + # Determine effective frequency range (Hz) + if vna_config: + freq_start = max(float(vna_config.get("start_freq", 100e6)), self._config["start_freq"] * 1e6) + freq_stop = min(float(vna_config.get("stop_freq", 8.8e9)), self._config["stop_freq"] * 1e6) + else: + freq_start = self._config["start_freq"] * 1e6 + freq_stop = self._config["stop_freq"] * 1e6 + + # Frequency vector over current data length + freq_axis = np.linspace(freq_start, freq_stop, complex_data.size, dtype=float) + + # Optionally normalize amplitude (phase-only modes) + limited = self._apply_data_limitations(complex_data) + + # IFFT to time domain + depth_m, time_response = self._perform_ifft(limited, freq_axis, axis=self._config["axis"]) + + # Depth windowing and gain shaping + depth_out, time_out = self._apply_depth_processing(depth_m, time_response) + + return { + "time_data": time_out, + "distance": depth_out, + "freq_range": [freq_start, freq_stop], + "complex_time": limited, + } + except Exception as exc: # noqa: BLE001 + logger.error("Data analysis failed", error=repr(exc)) + return None + + def _apply_data_limitations(self, s: NDArray[np.complex128]) -> NDArray[np.complex128]: + """ + Apply optional amplitude normalization to emphasize phase information. + + Modes + ----- + - None: passthrough + - "ph_only_1": normalize by magnitude + - "ph_only_2": same normalization (kept for behavioral parity) + """ + try: + mode = self._config.get("data_limitation") + if mode in {"ph_only_1", "ph_only_2"}: + # Avoid division by zero + return s / (np.abs(s) + 1e-12) + return s + except Exception as exc: # noqa: BLE001 + logger.error("Data limitation processing failed", error=repr(exc)) + return s + + def _perform_ifft( + self, + s_array: NDArray[np.complex128], + frequencies_hz: NDArray[np.floating], + *, + axis: str = "abs", + ) -> tuple[NDArray[np.floating], NDArray[np.floating]]: + """ + Frequency-to-time conversion with zero-padding and frequency offset handling. + + Returns + ------- + depth_m : ndarray + One-way distance in meters corresponding to the time axis. + y_fin : ndarray + Time-domain response (abs/real/phase per `axis`). + """ + try: + # Apply frequency band mask from current config (Hz) + start_hz = float(self._config.get("start_freq", 100.0)) * 1e6 + stop_hz = float(self._config.get("stop_freq", 8800.0)) * 1e6 + + mask = (frequencies_hz >= start_hz) & (frequencies_hz <= stop_hz) + f = frequencies_hz[mask] + s = s_array[mask] + + n = f.size + if n < 2: + raise ValueError("Not enough frequency points after filtering") + + df = (f[-1] - f[0]) / (n - 1) + if df <= 0.0: + raise ValueError("Non-increasing frequency grid") + + # Frequency offset index + k0 = int(np.round(f[0] / df)) + + # FFT size (next power of two for 2*(k0 + n - 1)) + min_len = 2 * (k0 + n - 1) + N_fft = 1 << int(np.ceil(np.log2(min_len))) + + dt = 1.0 / (N_fft * df) + t_sec = np.arange(N_fft, dtype=float) * dt + + # Build shifted spectrum + H = np.zeros(N_fft, dtype=np.complex128) + H[k0 : k0 + n] = s + + y = np.fft.ifft(H) + + # Convert time (s) to one-way distance (m): d = c * t + depth_m = t_sec * self.SPEED_OF_LIGHT_M_S + + if axis == "abs": + y_fin = np.abs(y) + elif axis == "real": + y_fin = np.real(y) + elif axis == "phase": + y_fin = np.angle(y) + else: + raise ValueError(f"Invalid axis parameter: {axis!r}") + + # Truncate output to computed length (optional: keep full) + return depth_m[: y_fin.size], y_fin.astype(float, copy=False) + + except Exception as exc: # noqa: BLE001 + logger.error("IFFT failed", error=repr(exc)) + # Graceful fallback: synthetic axes with magnitude of input + depth_fallback = np.linspace(0.0, 1.0, s_array.size, dtype=float) + return depth_fallback, np.abs(s_array).astype(float, copy=False) + + def _apply_depth_processing( + self, + depth_m: NDArray[np.floating], + response: NDArray[np.floating], + ) -> tuple[NDArray[np.floating], NDArray[np.floating]]: + """ + Apply depth windowing and gain shaping. + + Window + ------ + Keep samples where: + depth in [2*cut, 2*cut + 2*max] + Then map depth to: + depth' = (depth - 2*cut) / 2 + + Gain + ---- + response' = response * (depth' ** gain) + """ + try: + cut = float(self._config["cut"]) + max_depth = float(self._config["max"]) + gain = float(self._config["gain"]) + + # Window: account for two-way path in raw depth (factor 2) + lo = 2.0 * cut + hi = lo + 2.0 * max_depth + mask = (depth_m >= lo) & (depth_m <= hi) + + if not np.any(mask): + # Nothing in window; return empty arrays + return depth_m[:0], response[:0] + + depth_win = depth_m[mask] + resp_win = response[mask] + + # Convert to one-way depth relative to the cut + depth_out = (depth_win - lo) / 2.0 + + # Depth-dependent gain (safe for zero depth with exponent >= 0) + with np.errstate(invalid="ignore"): + gain_shape = np.power(depth_out, gain, dtype=float) + # Replace NaN/inf (e.g., 0**negative) with 0 + gain_shape[~np.isfinite(gain_shape)] = 0.0 + + response_out = resp_win * gain_shape + return depth_out, response_out + + except Exception as exc: # noqa: BLE001 + logger.error("Depth processing failed", error=repr(exc)) + return depth_m, response diff --git a/vna_system/core/processors/manager.py b/vna_system/core/processors/manager.py index c622fc0..6c3ccae 100644 --- a/vna_system/core/processors/manager.py +++ b/vna_system/core/processors/manager.py @@ -99,7 +99,7 @@ class ProcessorManager: # --------------------------------------------------------------------- # # Main processing actions # --------------------------------------------------------------------- # - def process_sweep(self, sweep_data: SweepData, calibrated_data: Any, vna_config: ConfigPreset | None) -> dict[str, ProcessedResult]: + def process_sweep(self, sweep_data: SweepData, calibrated_data: Any, vna_config: ConfigPreset | None, reference_data: Any = None) -> dict[str, ProcessedResult]: """ Feed a sweep into all processors and dispatch results to callbacks. @@ -113,7 +113,7 @@ class ProcessorManager: for processor_id, processor in processors_items: try: - result = processor.add_sweep_data(sweep_data, calibrated_data, vna_config) + result = processor.add_sweep_data(sweep_data, calibrated_data, vna_config, reference_data) if result: results[processor_id] = result for cb in callbacks: @@ -215,7 +215,8 @@ class ProcessorManager: 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) + reference_data = self.settings_manager.get_current_reference_sweep(vna_cfg) + self.process_sweep(latest, calibrated, vna_cfg, reference_data) self._last_processed_sweep = latest.sweep_number # Light-duty polling to reduce wakeups @@ -267,9 +268,11 @@ class ProcessorManager: """ try: from .implementations import MagnitudeProcessor, PhaseProcessor, SmithChartProcessor + from .implementations.bscan_processor import BScanProcessor self.register_processor(MagnitudeProcessor(self.config_dir)) self.register_processor(PhaseProcessor(self.config_dir)) + self.register_processor(BScanProcessor(self.config_dir)) # self.register_processor(SmithChartProcessor(self.config_dir)) logger.info("Default processors registered", count=len(self._processors)) diff --git a/vna_system/core/settings/reference_manager.py b/vna_system/core/settings/reference_manager.py new file mode 100644 index 0000000..d3432c3 --- /dev/null +++ b/vna_system/core/settings/reference_manager.py @@ -0,0 +1,464 @@ +""" +Open Air Reference Manager + +Manages open air reference sweep data for background subtraction and normalization. +References are stored per preset configuration and can be captured from live sweeps. +""" + +import json +import shutil +from datetime import datetime +from pathlib import Path + +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.settings.preset_manager import ConfigPreset + +logger = get_component_logger(__file__) + + +class ReferenceInfo: + """Information about a stored reference.""" + + def __init__( + self, + name: str, + timestamp: datetime, + preset_filename: str, + description: str = "", + metadata: dict | None = None + ): + self.name = name + self.timestamp = timestamp + self.preset_filename = preset_filename + self.description = description + self.metadata = metadata or {} + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + return { + "name": self.name, + "timestamp": self.timestamp.isoformat(), + "preset_filename": self.preset_filename, + "description": self.description, + "metadata": self.metadata + } + + @classmethod + def from_dict(cls, data: dict) -> "ReferenceInfo": + """Create from dictionary loaded from JSON.""" + return cls( + name=data["name"], + timestamp=datetime.fromisoformat(data["timestamp"]), + preset_filename=data["preset_filename"], + description=data.get("description", ""), + metadata=data.get("metadata", {}) + ) + + +class ReferenceManager: + """ + Manages open air reference sweep data. + + References are stored in the following structure: + references/ + ├── current_reference -> symlink to active reference + ├── / + │ ├── / + │ │ ├── info.json # Reference metadata + │ │ └── sweep_data.json # Raw sweep data + │ └── ... + └── ... + """ + + def __init__(self): + self.references_dir = Path(cfg.BASE_DIR) / "references" + self.current_reference_symlink = self.references_dir / "current_reference" + + # Ensure directory structure exists + self.references_dir.mkdir(exist_ok=True) + + logger.info("ReferenceManager initialized", references_dir=str(self.references_dir)) + + # ------------------------------------------------------------------ # + # Reference Creation and Storage + # ------------------------------------------------------------------ # + + def create_reference( + self, + name: str, + preset: ConfigPreset, + sweep_data: SweepData, + description: str = "", + metadata: dict | None = None + ) -> ReferenceInfo: + """ + Create a new reference from sweep data. + + Args: + name: Unique name for the reference + preset: Current configuration preset + sweep_data: Raw sweep data to store + description: Optional description + metadata: Optional additional metadata + + Returns: + ReferenceInfo: Created reference information + + Raises: + ValueError: If reference name already exists for this preset + OSError: If file operations fail + """ + if not name or not name.strip(): + raise ValueError("Reference name cannot be empty") + + preset_dir = self._get_preset_dir(preset) + reference_dir = preset_dir / name + + if reference_dir.exists(): + raise ValueError(f"Reference '{name}' already exists for preset {preset.filename}") + + # Create reference directory + reference_dir.mkdir(parents=True, exist_ok=True) + + # Create reference info + reference_info = ReferenceInfo( + name=name, + timestamp=datetime.now(), + preset_filename=preset.filename, + description=description.strip(), + metadata=metadata or {} + ) + + try: + # Save reference metadata + info_file = reference_dir / "info.json" + with info_file.open("w", encoding="utf-8") as f: + json.dump(reference_info.to_dict(), f, indent=2, ensure_ascii=False) + + # Save sweep data + sweep_file = reference_dir / "sweep_data.json" + sweep_dict = { + "sweep_number": sweep_data.sweep_number, + "timestamp": sweep_data.timestamp, + "points": sweep_data.points, + "total_points": sweep_data.total_points + } + with sweep_file.open("w", encoding="utf-8") as f: + json.dump(sweep_dict, f, indent=2) + + logger.info( + "Created reference", + name=name, + preset=preset.filename, + sweep_number=sweep_data.sweep_number, + points=len(sweep_data.points) + ) + + return reference_info + + except Exception as exc: + # Clean up on failure + if reference_dir.exists(): + shutil.rmtree(reference_dir, ignore_errors=True) + logger.error("Failed to create reference", name=name, error=repr(exc)) + raise + + def delete_reference(self, name: str, preset: ConfigPreset) -> bool: + """ + Delete a reference. + + Args: + name: Reference name to delete + preset: Configuration preset + + Returns: + bool: True if deleted successfully + """ + preset_dir = self._get_preset_dir(preset) + reference_dir = preset_dir / name + + if not reference_dir.exists(): + logger.warning("Reference not found for deletion", name=name, preset=preset.filename) + return False + + try: + # Check if this is the current reference + current_ref = self.get_current_reference(preset) + if current_ref and current_ref.name == name: + self.clear_current_reference() + + shutil.rmtree(reference_dir) + logger.info("Deleted reference", name=name, preset=preset.filename) + return True + + except Exception as exc: + logger.error("Failed to delete reference", name=name, error=repr(exc)) + return False + + # ------------------------------------------------------------------ # + # Reference Selection and Retrieval + # ------------------------------------------------------------------ # + + def set_current_reference(self, name: str, preset: ConfigPreset) -> bool: + """ + Set the current active reference. + + Args: + name: Reference name to activate + preset: Configuration preset + + Returns: + bool: True if set successfully + """ + preset_dir = self._get_preset_dir(preset) + reference_dir = preset_dir / name + + if not reference_dir.exists(): + raise ValueError(f"Reference '{name}' not found for preset {preset.filename}") + + try: + # Remove existing symlink + if self.current_reference_symlink.exists() or self.current_reference_symlink.is_symlink(): + self.current_reference_symlink.unlink() + + # Create relative symlink for portability + try: + relative_target = reference_dir.relative_to(self.references_dir) + except ValueError: + relative_target = reference_dir + + self.current_reference_symlink.symlink_to(relative_target) + + logger.info("Set current reference", name=name, preset=preset.filename) + return True + + except Exception as exc: + logger.error("Failed to set current reference", name=name, error=repr(exc)) + return False + + def get_current_reference(self, preset: ConfigPreset) -> ReferenceInfo | None: + """ + Get information about the currently active reference. + + Args: + preset: Configuration preset to check against + + Returns: + ReferenceInfo | None: Current reference info, or None if no reference is set + or if the current reference doesn't match the preset + """ + if not self.current_reference_symlink.exists(): + return None + + try: + target_dir = self.current_reference_symlink.resolve() + + if not target_dir.exists(): + logger.warning("Current reference symlink points to non-existent directory") + return None + + # Load reference info + info_file = target_dir / "info.json" + if not info_file.exists(): + logger.warning("Current reference missing info.json") + return None + + with info_file.open("r", encoding="utf-8") as f: + info_data = json.load(f) + + reference_info = ReferenceInfo.from_dict(info_data) + + # Check if reference matches current preset + if reference_info.preset_filename != preset.filename: + logger.debug( + "Current reference is for different preset", + reference_preset=reference_info.preset_filename, + current_preset=preset.filename + ) + return None + + return reference_info + + except Exception as exc: + logger.warning("Failed to get current reference", error=repr(exc)) + return None + + def clear_current_reference(self) -> bool: + """ + Clear the current reference selection. + + Returns: + bool: True if cleared successfully + """ + try: + if self.current_reference_symlink.exists() or self.current_reference_symlink.is_symlink(): + self.current_reference_symlink.unlink() + logger.info("Cleared current reference") + return True + except Exception as exc: + logger.error("Failed to clear current reference", error=repr(exc)) + return False + + def get_current_reference_sweep(self, preset: ConfigPreset) -> SweepData | None: + """ + Get the sweep data for the currently active reference. + + Args: + preset: Configuration preset + + Returns: + SweepData | None: Current reference sweep data, or None if no reference is active + """ + current_ref = self.get_current_reference(preset) + if not current_ref: + return None + + return self.get_reference_sweep(current_ref.name, preset) + + def get_reference_sweep(self, name: str, preset: ConfigPreset) -> SweepData | None: + """ + Get sweep data for a specific reference. + + Args: + name: Reference name + preset: Configuration preset + + Returns: + SweepData | None: Reference sweep data, or None if not found + """ + preset_dir = self._get_preset_dir(preset) + reference_dir = preset_dir / name + sweep_file = reference_dir / "sweep_data.json" + + if not sweep_file.exists(): + logger.warning("Reference sweep data not found", name=name, preset=preset.filename) + return None + + try: + with sweep_file.open("r", encoding="utf-8") as f: + sweep_dict = json.load(f) + + return SweepData( + sweep_number=sweep_dict["sweep_number"], + timestamp=sweep_dict["timestamp"], + points=sweep_dict["points"], + total_points=sweep_dict["total_points"] + ) + + except Exception as exc: + logger.error("Failed to load reference sweep data", name=name, error=repr(exc)) + return None + + # ------------------------------------------------------------------ # + # Reference Listing and Information + # ------------------------------------------------------------------ # + + def list_references(self, preset: ConfigPreset) -> list[ReferenceInfo]: + """ + List all references for a given preset. + + Args: + preset: Configuration preset + + Returns: + list[ReferenceInfo]: List of available references, sorted by timestamp (newest first) + """ + preset_dir = self._get_preset_dir(preset) + + if not preset_dir.exists(): + return [] + + references = [] + + for ref_dir in preset_dir.iterdir(): + if not ref_dir.is_dir(): + continue + + info_file = ref_dir / "info.json" + if not info_file.exists(): + logger.warning("Reference missing info.json", reference_dir=str(ref_dir)) + continue + + try: + with info_file.open("r", encoding="utf-8") as f: + info_data = json.load(f) + + reference_info = ReferenceInfo.from_dict(info_data) + references.append(reference_info) + + except Exception as exc: + logger.warning("Failed to load reference info", reference_dir=str(ref_dir), error=repr(exc)) + + # Sort by timestamp, newest first + references.sort(key=lambda r: r.timestamp, reverse=True) + return references + + def get_reference_info(self, name: str, preset: ConfigPreset) -> ReferenceInfo | None: + """ + Get information about a specific reference. + + Args: + name: Reference name + preset: Configuration preset + + Returns: + ReferenceInfo | None: Reference information, or None if not found + """ + preset_dir = self._get_preset_dir(preset) + reference_dir = preset_dir / name + info_file = reference_dir / "info.json" + + if not info_file.exists(): + return None + + try: + with info_file.open("r", encoding="utf-8") as f: + info_data = json.load(f) + + return ReferenceInfo.from_dict(info_data) + + except Exception as exc: + logger.warning("Failed to load reference info", name=name, error=repr(exc)) + return None + + # ------------------------------------------------------------------ # + # Helper Methods + # ------------------------------------------------------------------ # + + def _get_preset_dir(self, preset: ConfigPreset) -> Path: + """Get directory path for a preset's references.""" + # Use preset filename without extension as directory name + preset_name = Path(preset.filename).stem + return self.references_dir / preset_name + + def cleanup_orphaned_references(self) -> int: + """ + Clean up reference directories that don't have valid preset files. + + Returns: + int: Number of orphaned references cleaned up + """ + cleaned_count = 0 + + for preset_dir in self.references_dir.iterdir(): + if not preset_dir.is_dir() or preset_dir.name == "current_reference": + continue + + # Check if corresponding preset file exists + preset_filename = f"{preset_dir.name}.bin" + preset_path = Path(cfg.BASE_DIR) / "binary_input" / "config_inputs" / preset_filename + + if not preset_path.exists(): + try: + shutil.rmtree(preset_dir) + cleaned_count += 1 + logger.info("Cleaned up orphaned reference directory", preset=preset_dir.name) + except Exception as exc: + logger.warning("Failed to clean up orphaned references", preset=preset_dir.name, error=repr(exc)) + + if cleaned_count > 0: + logger.info("Reference cleanup completed", cleaned_count=cleaned_count) + + return cleaned_count \ No newline at end of file diff --git a/vna_system/core/settings/settings_manager.py b/vna_system/core/settings/settings_manager.py index 8e8d900..4dd1277 100644 --- a/vna_system/core/settings/settings_manager.py +++ b/vna_system/core/settings/settings_manager.py @@ -11,6 +11,7 @@ from vna_system.core.settings.calibration_manager import ( CalibrationSet, CalibrationStandard, ) +from vna_system.core.settings.reference_manager import ReferenceManager, ReferenceInfo logger = get_component_logger(__file__) @@ -38,6 +39,7 @@ class VNASettingsManager: # Sub-managers self.preset_manager = PresetManager(self.base_dir / "binary_input") self.calibration_manager = CalibrationManager(self.base_dir) + self.reference_manager = ReferenceManager() logger.debug( "VNASettingsManager initialized", @@ -50,11 +52,7 @@ class VNASettingsManager: def get_available_presets(self) -> list[ConfigPreset]: """Return all available configuration presets.""" return self.preset_manager.get_available_presets() - - def get_presets_by_mode(self, mode: VNAMode) -> list[ConfigPreset]: - """Return presets filtered by VNA mode.""" - return [p for p in self.get_available_presets() if p.mode == mode] - + def set_current_preset(self, preset: ConfigPreset) -> ConfigPreset: """Set the current configuration preset (updates the symlink).""" chosen = self.preset_manager.set_current_preset(preset) @@ -148,6 +146,48 @@ class VNASettingsManager: return [CalibrationStandard.THROUGH] return [] + # ------------------------------------------------------------------ # + # Reference Management + # ------------------------------------------------------------------ # + def get_available_references(self, preset: ConfigPreset | None = None) -> list[ReferenceInfo]: + """Return all available references for the given preset (or current preset).""" + current_preset = preset or self.get_current_preset() + if not current_preset: + return [] + return self.reference_manager.list_references(current_preset) + + def get_current_reference(self, preset: ConfigPreset | None = None) -> ReferenceInfo | None: + """Return the currently selected reference for the given preset (or current preset).""" + current_preset = preset or self.get_current_preset() + if not current_preset: + return None + return self.reference_manager.get_current_reference(current_preset) + + def set_current_reference(self, reference_name: str, preset: ConfigPreset | None = None) -> bool: + """Set the current reference for the given preset (or current preset).""" + current_preset = preset or self.get_current_preset() + if not current_preset: + raise ValueError("No current preset available") + return self.reference_manager.set_current_reference(reference_name, current_preset) + + def clear_current_reference(self) -> bool: + """Clear the current reference selection.""" + return self.reference_manager.clear_current_reference() + + def get_current_reference_sweep(self, preset: ConfigPreset | None = None) -> SweepData | None: + """Get sweep data for the currently selected reference.""" + current_preset = preset or self.get_current_preset() + if not current_preset: + return None + return self.reference_manager.get_current_reference_sweep(current_preset) + + def delete_reference(self, reference_name: str, preset: ConfigPreset | None = None) -> bool: + """Delete a reference for the given preset (or current preset).""" + current_preset = preset or self.get_current_preset() + if not current_preset: + raise ValueError("No current preset available") + return self.reference_manager.delete_reference(reference_name, current_preset) + # ------------------------------------------------------------------ # # Acquisition integration # ------------------------------------------------------------------ # @@ -169,7 +209,18 @@ class VNASettingsManager: RuntimeError If no sweep is available in the acquisition buffer. """ - latest = data_acquisition.sweep_buffer.get_latest_sweep() + # Get current sweep number before any operations + current_sweep = data_acquisition.sweep_buffer.get_latest_sweep() + current_sweep_number = current_sweep.sweep_number if current_sweep else -1 + + # Check if acquisition is running, if not - trigger single sweep + if not data_acquisition.is_running: + logger.info("Acquisition not running, triggering single sweep for calibration") + data_acquisition.trigger_single_sweep() + + # Wait for new sweep to appear (regardless of acquisition state) + latest = self._wait_for_new_sweep(data_acquisition.sweep_buffer, current_sweep_number) + if latest is None: raise RuntimeError("No sweep data available in acquisition buffer") @@ -181,6 +232,66 @@ class VNASettingsManager: ) return latest.sweep_number + def capture_reference_from_acquisition( + self, + reference_name: str, + data_acquisition: VNADataAcquisition, + description: str = "", + metadata: dict | None = None, + preset: ConfigPreset | None = None, + ) -> ReferenceInfo: + """ + Capture the latest sweep from acquisition as an open air reference. + + Args: + reference_name: Name for the new reference + data_acquisition: Data acquisition instance to capture from + description: Optional description for the reference + metadata: Optional additional metadata + preset: Preset to use (defaults to current preset) + + Returns: + ReferenceInfo: Created reference information + + Raises: + RuntimeError: If no sweep is available in the acquisition buffer + ValueError: If no current preset is available + """ + # Get current sweep number before any operations + current_sweep = data_acquisition.sweep_buffer.get_latest_sweep() + current_sweep_number = current_sweep.sweep_number if current_sweep else -1 + + # Check if acquisition is running, if not - trigger single sweep + if not data_acquisition.is_running: + logger.info("Acquisition not running, triggering single sweep for reference") + data_acquisition.trigger_single_sweep() + + # Wait for new sweep to appear (regardless of acquisition state) + latest = self._wait_for_new_sweep(data_acquisition.sweep_buffer, current_sweep_number) + + if latest is None: + raise RuntimeError("No sweep data available in acquisition buffer") + + current_preset = preset or self.get_current_preset() + if not current_preset: + raise ValueError("No current preset available") + + reference_info = self.reference_manager.create_reference( + name=reference_name, + preset=current_preset, + sweep_data=latest, + description=description, + metadata=metadata or {} + ) + + logger.info( + "Captured open air reference from acquisition", + reference_name=reference_name, + preset=current_preset.filename, + sweep_number=latest.sweep_number, + ) + return reference_info + def get_status_summary(self) -> dict[str, object]: """Get comprehensive status of current configuration and calibration""" current_preset = self.get_current_preset() @@ -223,4 +334,39 @@ class VNASettingsManager: "missing_standards": [s.value for s in working_calibration.get_missing_standards()] } - return summary \ No newline at end of file + return summary + + def _wait_for_new_sweep(self, sweep_buffer, current_sweep_number: int, timeout_seconds: float = 5.0) -> SweepData | None: + """ + Wait for a new sweep to appear in the buffer with a higher sweep number. + + Args: + sweep_buffer: The sweep buffer to monitor + current_sweep_number: Current sweep number to wait beyond + timeout_seconds: Maximum time to wait + + Returns: + SweepData | None: New sweep data, or None if timeout occurred + """ + import time + start_time = time.time() + + while time.time() - start_time < timeout_seconds: + latest = sweep_buffer.get_latest_sweep() + if latest and latest.sweep_number > current_sweep_number: + logger.debug( + "New sweep detected", + old_sweep=current_sweep_number, + new_sweep=latest.sweep_number + ) + return latest + + # Short sleep to avoid busy waiting + time.sleep(0.01) + + logger.warning( + "Timeout waiting for new sweep", + current_sweep=current_sweep_number, + timeout=timeout_seconds + ) + return None \ No newline at end of file diff --git a/vna_system/references/current_reference b/vna_system/references/current_reference new file mode 120000 index 0000000..0911f55 --- /dev/null +++ b/vna_system/references/current_reference @@ -0,0 +1 @@ +s11_start100_stop8800_points1000_bw1khz/test1 \ No newline at end of file diff --git a/vna_system/web_ui/static/css/settings.css b/vna_system/web_ui/static/css/settings.css index 52ccf8a..9cad7d4 100644 --- a/vna_system/web_ui/static/css/settings.css +++ b/vna_system/web_ui/static/css/settings.css @@ -489,4 +489,138 @@ .calibration-standard-btn.btn--warning:hover:not(:disabled) { background-color: var(--color-warning-600); border-color: var(--color-warning-600); +} + +/* ======================================== + Reference Management Styles + ======================================== */ + +/* Reference Creation */ +.reference-creation { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +.reference-name-input, +.reference-description-input { + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-sm); + background: var(--color-bg); + color: var(--color-text); + transition: all 0.2s ease; +} + +.reference-name-input:focus, +.reference-description-input:focus { + outline: none; + border-color: var(--color-primary-500); + box-shadow: 0 0 0 3px var(--color-primary-100); +} + +.reference-name-input::placeholder, +.reference-description-input::placeholder { + color: var(--color-text-secondary); +} + +/* Reference Actions */ +.reference-actions { + display: flex; + gap: var(--space-3); + align-items: stretch; + margin-top: var(--space-3); + flex-wrap: wrap; +} + +.reference-dropdown { + flex: 1; + min-width: 200px; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-sm); + background: var(--color-bg); + color: var(--color-text); +} + +.reference-dropdown:disabled { + opacity: 0.6; + cursor: not-allowed; + background: var(--color-gray-50); +} + +/* Current Reference Info */ +.current-reference-info { + margin-top: var(--space-4); + padding-top: var(--space-4); + border-top: 1px solid var(--color-border); +} + +.reference-info-card { + background: var(--color-gray-800); + border: 1px solid var(--color-gray-600); + border-radius: var(--radius-lg); + padding: var(--space-4); +} + +.reference-info-card h5 { + margin: 0 0 var(--space-3) 0; + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--color-gray-300); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.reference-details { + display: flex; + flex-direction: column; + gap: var(--space-2); + background: var(--color-gray-900); + padding: var(--space-3); + border-radius: var(--radius-md); + border: 1px solid var(--color-gray-700); +} + +.reference-name { + font-size: var(--text-base); + font-weight: var(--weight-semibold); + color: var(--color-white); +} + +.reference-timestamp { + font-size: var(--text-sm); + color: var(--color-gray-300); + font-family: var(--font-mono); +} + +.reference-description { + margin: 0; + font-size: var(--text-sm); + color: var(--color-gray-400); + font-style: italic; +} + +.reference-description:empty::before { + content: "No description provided"; + opacity: 0.7; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .reference-creation { + gap: var(--space-2); + } + + .reference-actions { + flex-direction: column; + gap: var(--space-2); + } + + .reference-dropdown { + min-width: unset; + } } \ No newline at end of file diff --git a/vna_system/web_ui/static/js/modules/settings.js b/vna_system/web_ui/static/js/modules/settings.js index 6ddd947..b6ad17e 100644 --- a/vna_system/web_ui/static/js/modules/settings.js +++ b/vna_system/web_ui/static/js/modules/settings.js @@ -90,12 +90,11 @@ export class SettingsManager { this.currentPreset = null; this.currentCalibration = null; this.workingCalibration = null; + this.availableReferences = []; + this.currentReference = null; - // Калибровка: ожидание свипа - this.waitingForSweep = false; - this.pendingStandard = null; + // Калибровка: состояние захвата this.disabledStandards = new Set(); - this.calibrationTimeout = null; // DOM cache this.elements = {}; @@ -105,7 +104,6 @@ export class SettingsManager { this.reqGuard = new RequestGuard(); // Единственный bound-обработчик, чтобы корректно отписываться - this._boundHandleSweepForCalibration = this.handleSweepForCalibration.bind(this); // Bind UI handlers this.handlePresetChange = this.handlePresetChange.bind(this); @@ -118,6 +116,13 @@ export class SettingsManager { this.handleViewPlots = this.handleViewPlots.bind(this); this.handleViewCurrentPlots = this.handleViewCurrentPlots.bind(this); + // Reference handlers + this.handleCreateReference = this.handleCreateReference.bind(this); + this.handleReferenceChange = this.handleReferenceChange.bind(this); + this.handleSetReference = this.handleSetReference.bind(this); + this.handleClearReference = this.handleClearReference.bind(this); + this.handleDeleteReference = this.handleDeleteReference.bind(this); + // Пакет данных для модалки с графиками this.currentPlotsData = null; } @@ -179,6 +184,19 @@ export class SettingsManager { plotsGrid: document.getElementById('plotsGrid'), downloadAllBtn: document.getElementById('downloadAllBtn'), + // References + referenceNameInput: document.getElementById('referenceNameInput'), + referenceDescriptionInput: document.getElementById('referenceDescriptionInput'), + createReferenceBtn: document.getElementById('createReferenceBtn'), + referenceDropdown: document.getElementById('referenceDropdown'), + setReferenceBtn: document.getElementById('setReferenceBtn'), + clearReferenceBtn: document.getElementById('clearReferenceBtn'), + deleteReferenceBtn: document.getElementById('deleteReferenceBtn'), + currentReferenceInfo: document.getElementById('currentReferenceInfo'), + currentReferenceName: document.getElementById('currentReferenceName'), + currentReferenceTimestamp: document.getElementById('currentReferenceTimestamp'), + currentReferenceDescription: document.getElementById('currentReferenceDescription'), + // Status presetCount: document.getElementById('presetCount'), calibrationCount: document.getElementById('calibrationCount'), @@ -199,6 +217,13 @@ export class SettingsManager { this.elements.viewPlotsBtn?.addEventListener('click', this.handleViewPlots); this.elements.viewCurrentPlotsBtn?.addEventListener('click', this.handleViewCurrentPlots); + // References + this.elements.createReferenceBtn?.addEventListener('click', this.handleCreateReference); + this.elements.referenceDropdown?.addEventListener('change', this.handleReferenceChange); + this.elements.setReferenceBtn?.addEventListener('click', this.handleSetReference); + this.elements.clearReferenceBtn?.addEventListener('click', this.handleClearReference); + this.elements.deleteReferenceBtn?.addEventListener('click', this.handleDeleteReference); + // Name input → enables Save this.elements.calibrationNameInput?.addEventListener('input', () => { const hasName = this.elements.calibrationNameInput.value.trim().length > 0; @@ -217,6 +242,13 @@ export class SettingsManager { this.elements.viewPlotsBtn?.removeEventListener('click', this.handleViewPlots); this.elements.viewCurrentPlotsBtn?.removeEventListener('click', this.handleViewCurrentPlots); + // References + this.elements.createReferenceBtn?.removeEventListener('click', this.handleCreateReference); + this.elements.referenceDropdown?.removeEventListener('change', this.handleReferenceChange); + this.elements.setReferenceBtn?.removeEventListener('click', this.handleSetReference); + this.elements.clearReferenceBtn?.removeEventListener('click', this.handleClearReference); + this.elements.deleteReferenceBtn?.removeEventListener('click', this.handleDeleteReference); + // WebSocket if (this.websocket) { this.websocket.off?.('processor_result', this._boundHandleSweepForCalibration); @@ -229,7 +261,8 @@ export class SettingsManager { await Promise.all([ this._loadPresets(), this._loadStatus(), - this._loadWorkingCalibration() + this._loadWorkingCalibration(), + this._loadReferences() ]); } @@ -464,7 +497,11 @@ export class SettingsManager { } if (this.elements.saveCalibrationBtn) this.elements.saveCalibrationBtn.disabled = true; if (this.elements.progressText) this.elements.progressText.textContent = '0/0'; - console.log('🔄 Calibration UI reset after preset change'); + + // Reset reference state + this.availableReferences = []; + this.currentReference = null; + console.log('🔄 Calibration and reference UI reset after preset change'); } /* ----------------------------- Event Handlers (UI) ----------------------------- */ @@ -554,39 +591,30 @@ export class SettingsManager { this.disabledStandards.add(standard); const btn = document.querySelector(`[data-standard="${standard}"]`); - ButtonState.set(btn, { state: 'loading', icon: 'clock', text: 'Waiting for next sweep...' }); + ButtonState.set(btn, { state: 'loading', icon: 'upload', text: 'Capturing...' }); - // Если acquisition не работает — попросим один свип - const running = this.acquisition?.isRunning?.() ?? false; - if (!running) { - this._notify('info', 'Triggering Sweep', `Requesting single sweep for ${standard.toUpperCase()} standard`); - try { - await this.acquisition?.triggerSingleSweep?.(); - } catch (e) { - console.error('Trigger sweep failed:', e); - this._notify('error', 'Sweep Error', 'Failed to trigger single sweep for calibration'); - this._resetCalibrationCaptureState(standard); - return; - } - } else { - // Если уже работает — просим пользователя запустить новый свип (или просто ждём) - this._notify('info', 'Waiting for Sweep', `Please trigger a new sweep to capture ${standard.toUpperCase()} standard`); - } + this._notify('info', 'Capturing Standard', `Capturing ${standard.toUpperCase()} standard...`); - // Ожидаем следующий свип через WebSocket; подписка единым обработчиком - this.waitingForSweep = true; - this.pendingStandard = standard; - this.websocket?.on?.('processor_result', this._boundHandleSweepForCalibration); + // Прямой вызов API - бэкенд сам обработает ожидание и триггеринг + const r = await fetch('/api/v1/settings/calibration/add-standard', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ standard }) + }); - // Таймаут ожидания (5с) - this._clearCalibrationTimeout(); - this.calibrationTimeout = setTimeout(() => { - this._notify('warning', 'Calibration Timeout', `No sweep received within 5 seconds for ${standard.toUpperCase()}. Please try again.`); - this._resetCalibrationCaptureState(standard); - }, 5000); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const result = await r.json(); + + this._notify('success', 'Standard Captured', result.message); + + // Сброс состояния + this._resetCalibrationCaptureState(); + + // Обновить рабочую калибровку + await this._loadWorkingCalibration(); } catch (e) { - console.error('Start standard capture failed:', e); - this._notify('error', 'Calibration Error', 'Failed to start calibration standard capture'); + console.error('Capture standard failed:', e); + this._notify('error', 'Calibration Error', 'Failed to capture calibration standard'); this._resetCalibrationCaptureState(standard); } }), 500 @@ -716,65 +744,16 @@ export class SettingsManager { /* ----------------------------- WebSocket sweep capture ----------------------------- */ - async handleSweepForCalibration() { - // реагируем только если действительно ждём свип - if (!this.waitingForSweep || !this.pendingStandard) return; - - try { - console.log(`📡 New sweep → capture ${this.pendingStandard}...`); - - // Сразу отключаем подписку и таймер - this.websocket?.off?.('processor_result', this._boundHandleSweepForCalibration); - this._clearCalibrationTimeout(); - - const btn = document.querySelector(`[data-standard="${this.pendingStandard}"]`); - ButtonState.set(btn, { state: 'loading', icon: 'upload', text: 'Capturing...' }); - - const r = await fetch('/api/v1/settings/calibration/add-standard', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ standard: this.pendingStandard }) - }); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const result = await r.json(); - - this._notify('success', 'Standard Captured', result.message); - - // Сброс - this._resetCalibrationCaptureState(); - - // Обновить рабочую калибровку - await this._loadWorkingCalibration(); - } catch (e) { - console.error('Capture standard failed:', e); - this._notify('error', 'Calibration Error', 'Failed to capture calibration standard'); - this._resetCalibrationCaptureState(); - } - } _resetCalibrationCaptureState(standard = null) { if (standard) this.disabledStandards.delete(standard); else this.disabledStandards.clear(); - if (!standard || standard === this.pendingStandard) { - this.waitingForSweep = false; - this.pendingStandard = null; - - this.websocket?.off?.('processor_result', this._boundHandleSweepForCalibration); - this._clearCalibrationTimeout(); - } - if (this.workingCalibration) { this._renderStandardButtons(this.workingCalibration); } } - _clearCalibrationTimeout() { - if (this.calibrationTimeout) { - clearTimeout(this.calibrationTimeout); - this.calibrationTimeout = null; - } - } /* ----------------------------- Plots Modal ----------------------------- */ @@ -1230,26 +1209,242 @@ export class SettingsManager { /* ----------------------------- Helpers ----------------------------- */ + /* ----------------------------- Reference Management ----------------------------- */ + + async _loadReferences() { + try { + if (!this.currentPreset) { + this._renderReferencesDropdown([]); + this._updateCurrentReferenceInfo(null); + return; + } + + // Load available references + const referencesResponse = await fetch(`/api/v1/settings/references?preset_filename=${encodeURIComponent(this.currentPreset.filename)}`); + if (!referencesResponse.ok) throw new Error(`HTTP ${referencesResponse.status}`); + this.availableReferences = await referencesResponse.json(); + + // Load current reference + const currentResponse = await fetch(`/api/v1/settings/reference/current?preset_filename=${encodeURIComponent(this.currentPreset.filename)}`); + if (!currentResponse.ok) throw new Error(`HTTP ${currentResponse.status}`); + this.currentReference = await currentResponse.json(); + + this._renderReferencesDropdown(this.availableReferences); + this._updateCurrentReferenceInfo(this.currentReference); + } catch (error) { + console.error('Failed to load references:', error); + this._renderReferencesDropdown([]); + this._updateCurrentReferenceInfo(null); + } + } + + _renderReferencesDropdown(references) { + if (!this.elements.referenceDropdown) return; + + // Clear existing options + this.elements.referenceDropdown.innerHTML = ''; + + if (references.length === 0) { + this.elements.referenceDropdown.innerHTML = ''; + this.elements.referenceDropdown.disabled = true; + this.elements.setReferenceBtn.disabled = true; + this.elements.clearReferenceBtn.disabled = true; + this.elements.deleteReferenceBtn.disabled = true; + } else { + this.elements.referenceDropdown.innerHTML = ''; + + references.forEach(ref => { + const option = document.createElement('option'); + option.value = ref.name; + option.textContent = `${ref.name} (${new Date(ref.timestamp).toLocaleDateString()})`; + this.elements.referenceDropdown.appendChild(option); + }); + + this.elements.referenceDropdown.disabled = false; + + // Select current reference if any + if (this.currentReference) { + this.elements.referenceDropdown.value = this.currentReference.name; + } + } + + this._updateReferenceButtons(); + } + + _updateReferenceButtons() { + const hasSelection = this.elements.referenceDropdown.value !== ''; + const hasCurrent = this.currentReference !== null; + + this.elements.setReferenceBtn.disabled = !hasSelection; + this.elements.deleteReferenceBtn.disabled = !hasSelection; + this.elements.clearReferenceBtn.disabled = !hasCurrent; + } + + _updateCurrentReferenceInfo(reference) { + if (!this.elements.currentReferenceInfo) return; + + if (!reference) { + this.elements.currentReferenceInfo.style.display = 'none'; + return; + } + + this.elements.currentReferenceInfo.style.display = 'block'; + + if (this.elements.currentReferenceName) { + this.elements.currentReferenceName.textContent = reference.name; + } + + if (this.elements.currentReferenceTimestamp) { + this.elements.currentReferenceTimestamp.textContent = new Date(reference.timestamp).toLocaleString(); + } + + if (this.elements.currentReferenceDescription) { + this.elements.currentReferenceDescription.textContent = reference.description || ''; + } + } + + async handleCreateReference() { + const name = this.elements.referenceNameInput.value.trim(); + if (!name) { + this._notify('warning', 'Missing Name', 'Please enter a name for the reference'); + return; + } + + const description = this.elements.referenceDescriptionInput.value.trim(); + + this.debouncer.debounce('create-reference', () => + this.reqGuard.runExclusive('create-reference', async () => { + try { + ButtonState.set(this.elements.createReferenceBtn, { state: 'loading', icon: 'loader', text: 'Creating...' }); + + const response = await fetch('/api/v1/settings/reference/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description }) + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + + this._notify('success', 'Reference Created', result.message); + + // Clear inputs + this.elements.referenceNameInput.value = ''; + this.elements.referenceDescriptionInput.value = ''; + + // Reload references + await this._loadReferences(); + } catch (error) { + console.error('Create reference failed:', error); + this._notify('error', 'Reference Error', 'Failed to create reference'); + } finally { + ButtonState.set(this.elements.createReferenceBtn, { state: 'normal', icon: 'target', text: 'Capture Reference' }); + } + }), 500 + ); + } + + handleReferenceChange() { + this._updateReferenceButtons(); + } + + async handleSetReference() { + const referenceName = this.elements.referenceDropdown.value; + if (!referenceName) return; + + this.debouncer.debounce('set-reference', () => + this.reqGuard.runExclusive('set-reference', async () => { + try { + ButtonState.set(this.elements.setReferenceBtn, { state: 'loading', icon: 'loader', text: 'Setting...' }); + + const response = await fetch('/api/v1/settings/reference/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: referenceName }) + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + + this._notify('success', 'Reference Set', result.message); + + // Reload references to update current + await this._loadReferences(); + } catch (error) { + console.error('Set reference failed:', error); + this._notify('error', 'Reference Error', 'Failed to set reference'); + } finally { + ButtonState.set(this.elements.setReferenceBtn, { state: 'normal', icon: 'check', text: 'Set Active' }); + } + }), 400 + ); + } + + async handleClearReference() { + this.debouncer.debounce('clear-reference', () => + this.reqGuard.runExclusive('clear-reference', async () => { + try { + ButtonState.set(this.elements.clearReferenceBtn, { state: 'loading', icon: 'loader', text: 'Clearing...' }); + + const response = await fetch('/api/v1/settings/reference/current', { + method: 'DELETE' + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + + this._notify('success', 'Reference Cleared', result.message); + + // Reload references to update current + await this._loadReferences(); + } catch (error) { + console.error('Clear reference failed:', error); + this._notify('error', 'Reference Error', 'Failed to clear reference'); + } finally { + ButtonState.set(this.elements.clearReferenceBtn, { state: 'normal', icon: 'x', text: 'Clear' }); + } + }), 400 + ); + } + + async handleDeleteReference() { + const referenceName = this.elements.referenceDropdown.value; + if (!referenceName) return; + + // Confirm deletion + if (!confirm(`Are you sure you want to delete reference "${referenceName}"? This action cannot be undone.`)) { + return; + } + + this.debouncer.debounce('delete-reference', () => + this.reqGuard.runExclusive('delete-reference', async () => { + try { + ButtonState.set(this.elements.deleteReferenceBtn, { state: 'loading', icon: 'loader', text: 'Deleting...' }); + + const response = await fetch(`/api/v1/settings/reference/${encodeURIComponent(referenceName)}`, { + method: 'DELETE' + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + + this._notify('success', 'Reference Deleted', result.message); + + // Reload references + await this._loadReferences(); + } catch (error) { + console.error('Delete reference failed:', error); + this._notify('error', 'Reference Error', 'Failed to delete reference'); + } finally { + ButtonState.set(this.elements.deleteReferenceBtn, { state: 'normal', icon: 'trash-2', text: 'Delete' }); + } + }), 400 + ); + } + _notify(type, title, message) { this.notifications?.show?.({ type, title, message }); } - _resetCalibrationCaptureState(standard = null) { - // публичная версия для внешних вызовов - this._resetCalibrationCaptureStateInternal(standard); - } - _resetCalibrationCaptureStateInternal(standard = null) { - if (standard) this.disabledStandards.delete(standard); - else this.disabledStandards.clear(); - - if (!standard || standard === this.pendingStandard) { - this.waitingForSweep = false; - this.pendingStandard = null; - this.websocket?.off?.('processor_result', this._boundHandleSweepForCalibration); - this._clearCalibrationTimeout(); - } - - if (this.workingCalibration) this._renderStandardButtons(this.workingCalibration); - } } diff --git a/vna_system/web_ui/templates/index.html b/vna_system/web_ui/templates/index.html index 850272d..400c6cd 100644 --- a/vna_system/web_ui/templates/index.html +++ b/vna_system/web_ui/templates/index.html @@ -244,6 +244,62 @@ + +
+

Open Air Reference

+
+ + +
+

Create Reference

+
+ + + +
+
+ + +
+

Existing References

+
+ +
+ + + +
+
+ + + +
+
+
+

Status Summary