Compare commits

..

14 Commits

Author SHA1 Message Date
awe
e43ce26fdf parser v0 2025-12-01 20:25:17 +03:00
awe
bfc3949c4d interface upd 2025-11-27 18:25:56 +03:00
awe
2742cfe856 working_laser control 2025-11-27 18:11:33 +03:00
awe
c64f2a4d6b acquire data from pipe 2025-11-27 15:57:49 +03:00
awe
8599e3cb55 scan mode 2025-11-25 13:44:28 +03:00
awe
327baee051 fix naming 2025-11-24 22:22:48 +03:00
awe
88ef718f31 вроде воркает 2025-11-24 22:21:30 +03:00
awe
5aea37ac63 frontend first 2025-11-24 21:09:28 +03:00
c1f5d6580e added requirement 2025-11-21 19:54:05 +03:00
23bc4bdd24 Merge branch 'master' of 10.55.228.171:/home/mipt-user/projects/vna_system 2025-11-19 17:47:57 +03:00
2b2e323fbf Added normalization and detection. 2025-11-19 17:47:31 +03:00
20666e894f added settings load window 2025-11-19 16:22:44 +03:00
333ec5d196 Gaussian smoothing. 2025-11-17 18:03:41 +03:00
174ab59004 removed saving notifications 2025-11-10 13:45:03 +03:00
27 changed files with 2933 additions and 140 deletions

0
__init__.py Normal file
View File

View File

@ -4,4 +4,5 @@ fastapi==0.117.1
numpy==2.3.3 numpy==2.3.3
matplotlib==3.10.6 matplotlib==3.10.6
plotly==6.3.0 plotly==6.3.0
scipy==1.16.3
# ??? kaleido==1.1.0 # ??? kaleido==1.1.0

0
vna_system/__init__.py Normal file
View File

View File

@ -0,0 +1,215 @@
from fastapi import APIRouter, HTTPException, Depends
from typing import Dict, Any
import logging
from ...api.models.laser import (
LaserParameters,
LaserStatus,
LaserStartResponse,
LaserStopResponse,
ManualLaserParameters,
ManualLaserStartResponse
)
from ...core.laser.laser_controller import LaserController
from ...core import singletons
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/laser", tags=["laser"])
def get_laser_controller():
"""Dependency to get laser controller instance from singletons"""
return singletons.laser_controller_instance
@router.post("/start", response_model=LaserStartResponse)
async def start_laser_cycle(
parameters: LaserParameters,
controller: LaserController = Depends(get_laser_controller)
) -> LaserStartResponse:
"""
Start laser control cycle with specified parameters.
This endpoint accepts laser control parameters and starts the measurement cycle.
All parameters are validated and logged.
"""
try:
logger.info("Received request to start laser cycle")
# Validate that only one scan mode is enabled at a time
# (Manual mode = all disabled, Scan mode = one enabled)
enabled_modes = sum([
parameters.enable_t1,
parameters.enable_t2,
parameters.enable_c1,
parameters.enable_c2
])
if enabled_modes > 1:
raise HTTPException(
status_code=400,
detail="Можно включить только один режим сканирования одновременно"
)
# Start the cycle
result = controller.start_cycle(parameters)
if not result["success"]:
raise HTTPException(status_code=500, detail=result["message"])
return LaserStartResponse(
success=True,
message=result["message"],
parameters=parameters
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error starting laser cycle: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@router.post("/start-manual", response_model=ManualLaserStartResponse)
async def start_manual_laser_control(
parameters: ManualLaserParameters,
controller: LaserController = Depends(get_laser_controller)
) -> ManualLaserStartResponse:
"""
Start manual laser control with simplified parameters (t1, t2, i1, i2).
This endpoint is designed for direct manual control without scan modes.
It accepts only 4 parameters and immediately applies them to the device.
Args:
parameters: Manual control parameters with t1, t2, i1, i2
Returns:
ManualLaserStartResponse with success status and message
"""
try:
logger.info("Received request to start manual laser control")
logger.info(f"Parameters: T1={parameters.t1}°C, T2={parameters.t2}°C, I1={parameters.i1}mA, I2={parameters.i2}mA")
# Call the simplified manual control method
result = controller.start_manual_direct(
t1=parameters.t1,
t2=parameters.t2,
i1=parameters.i1,
i2=parameters.i2
)
if not result["success"]:
raise HTTPException(status_code=500, detail=result["message"])
return ManualLaserStartResponse(
success=True,
message=result["message"],
parameters=parameters
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error starting manual laser control: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@router.post("/stop", response_model=LaserStopResponse)
async def stop_laser_cycle(
controller: LaserController = Depends(get_laser_controller)
) -> LaserStopResponse:
"""
Stop the current laser control cycle.
"""
try:
logger.info("Received request to stop laser cycle")
result = controller.stop_cycle()
if not result["success"]:
raise HTTPException(status_code=500, detail=result["message"])
return LaserStopResponse(
success=True,
message=result["message"]
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error stopping laser cycle: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@router.get("/status", response_model=LaserStatus)
async def get_laser_status(
controller: LaserController = Depends(get_laser_controller)
) -> LaserStatus:
"""
Get current laser status including temperature, current, and voltage readings.
"""
try:
status = controller.get_status()
return status
except Exception as e:
logger.error(f"Error getting laser status: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Ошибка получения статуса: {str(e)}")
@router.post("/connect")
async def connect_laser(
port: str = None,
controller: LaserController = Depends(get_laser_controller)
) -> Dict[str, Any]:
"""
Connect to laser control hardware on specified port.
Args:
port: Serial port (e.g., '/dev/ttyUSB0'). If not specified, auto-detect.
"""
try:
logger.info(f"Received request to connect to laser hardware on port: {port or 'auto-detect'}")
# If already connected, disconnect first
if controller.is_connected:
logger.info("Already connected, disconnecting first...")
controller.disconnect()
result = controller.connect(port)
if not result["success"]:
raise HTTPException(status_code=500, detail=result["message"])
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error connecting to laser: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Ошибка подключения: {str(e)}")
@router.post("/disconnect")
async def disconnect_laser(
controller: LaserController = Depends(get_laser_controller)
) -> Dict[str, Any]:
"""
Disconnect from laser control hardware.
"""
try:
logger.info("Received request to disconnect from laser hardware")
result = controller.disconnect()
if not result["success"]:
raise HTTPException(status_code=500, detail=result["message"])
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error disconnecting from laser: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Ошибка отключения: {str(e)}")

View File

@ -0,0 +1,120 @@
from pydantic import BaseModel, Field, field_validator
from typing import Optional
class LaserParameters(BaseModel):
"""Model for laser control parameters"""
# Laser 1 Temperature parameters
min_temp_1: float = Field(..., ge=-1, le=45, description="Минимальная температура лазера 1 (C)")
max_temp_1: float = Field(..., ge=-1, le=45, description="Максимальная температура лазера 1 (C)")
delta_temp_1: float = Field(..., ge=0.05, le=1.0, description="Шаг дискретизации температуры лазера 1 (C)")
# Laser 1 Current parameters
min_current_1: float = Field(..., ge=15, le=70, description="Минимальный ток лазера 1 (мА)")
max_current_1: float = Field(..., ge=15, le=70, description="Максимальный ток лазера 1 (мА)")
delta_current_1: float = Field(..., ge=0.002, le=0.5, description="Шаг дискретизации тока лазера 1 (мА)")
# Laser 2 Temperature parameters
min_temp_2: float = Field(..., ge=-1, le=45, description="Минимальная температура лазера 2 (C)")
max_temp_2: float = Field(..., ge=-1, le=45, description="Максимальная температура лазера 2 (C)")
delta_temp_2: float = Field(..., ge=0.05, le=1.0, description="Шаг дискретизации температуры лазера 2 (C)")
# Laser 2 Current parameters
min_current_2: float = Field(..., ge=15, le=60, description="Минимальный ток лазера 2 (мА)")
max_current_2: float = Field(..., ge=15, le=60, description="Максимальный ток лазера 2 (мА)")
delta_current_2: float = Field(..., ge=0.002, le=0.5, description="Шаг дискретизации тока лазера 2 (мА)")
# Time parameters
delta_time: int = Field(..., ge=20, le=100, description="Шаг дискретизации времени (мкс), шаг 10")
tau: int = Field(..., ge=3, le=10, description="Время задержки (мс)")
# Enable flags for different control modes
enable_t1: bool = Field(False, description="Включить изменение температуры лазера 1")
enable_t2: bool = Field(False, description="Включить изменение температуры лазера 2")
enable_c1: bool = Field(False, description="Включить изменение тока лазера 1")
enable_c2: bool = Field(False, description="Включить изменение тока лазера 2")
@field_validator('delta_time')
@classmethod
def validate_delta_time(cls, v):
if v % 10 != 0:
raise ValueError('delta_time должен быть кратен 10 мкс')
return v
@field_validator('max_temp_1')
@classmethod
def validate_temp_1_range(cls, v, info):
if 'min_temp_1' in info.data and v < info.data['min_temp_1']:
raise ValueError('max_temp_1 должен быть больше min_temp_1')
return v
@field_validator('max_temp_2')
@classmethod
def validate_temp_2_range(cls, v, info):
if 'min_temp_2' in info.data and v < info.data['min_temp_2']:
raise ValueError('max_temp_2 должен быть больше min_temp_2')
return v
@field_validator('max_current_1')
@classmethod
def validate_current_1_range(cls, v, info):
if 'min_current_1' in info.data and v < info.data['min_current_1']:
raise ValueError('max_current_1 должен быть больше min_current_1')
return v
@field_validator('max_current_2')
@classmethod
def validate_current_2_range(cls, v, info):
if 'min_current_2' in info.data and v < info.data['min_current_2']:
raise ValueError('max_current_2 должен быть больше min_current_2')
return v
class LaserStatus(BaseModel):
"""Model for laser status response"""
temp_1: Optional[float] = Field(None, description="Текущая температура лазера 1 (C)")
temp_2: Optional[float] = Field(None, description="Текущая температура лазера 2 (C)")
current_1: Optional[float] = Field(None, description="Текущий ток фотодиода 1 (мА)")
current_2: Optional[float] = Field(None, description="Текущий ток фотодиода 2 (мА)")
temp_ext_1: Optional[float] = Field(None, description="Температура внешнего термистора 1 (C)")
temp_ext_2: Optional[float] = Field(None, description="Температура внешнего термистора 2 (C)")
voltage_3v3: Optional[float] = Field(None, description="Напряжение 3V3 (В)")
voltage_5v1: Optional[float] = Field(None, description="Напряжение 5V1 (В)")
voltage_5v2: Optional[float] = Field(None, description="Напряжение 5V2 (В)")
voltage_7v0: Optional[float] = Field(None, description="Напряжение 7V0 (В)")
is_running: bool = Field(False, description="Запущен ли цикл измерений")
connected: bool = Field(False, description="Подключено ли устройство")
class LaserStartResponse(BaseModel):
"""Response model for laser start endpoint"""
success: bool = Field(..., description="Успешность операции")
message: str = Field(..., description="Сообщение о результате")
parameters: Optional[LaserParameters] = Field(None, description="Примененные параметры")
class LaserStopResponse(BaseModel):
"""Response model for laser stop endpoint"""
success: bool = Field(..., description="Успешность операции")
message: str = Field(..., description="Сообщение о результате")
class ManualLaserParameters(BaseModel):
"""Model for manual laser control with simple parameters"""
t1: float = Field(..., ge=-1, le=45, description="Температура лазера 1 (°C)")
t2: float = Field(..., ge=-1, le=45, description="Температура лазера 2 (°C)")
i1: float = Field(..., ge=15, le=70, description="Ток лазера 1 (мА)")
i2: float = Field(..., ge=15, le=60, description="Ток лазера 2 (мА)")
class ManualLaserStartResponse(BaseModel):
"""Response model for manual laser start endpoint"""
success: bool = Field(..., description="Успешность операции")
message: str = Field(..., description="Сообщение о результате")
parameters: Optional[ManualLaserParameters] = Field(None, description="Примененные параметры")

View File

View File

@ -1,5 +1,6 @@
import io import io
import json import json
import math
import os import os
import struct import struct
import threading import threading
@ -192,6 +193,67 @@ class VNADataAcquisition:
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
# Acquisition loop # Acquisition loop
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
def _radar_pipe_acquisition_loop(self) -> None:
"""Acquisition loop for reading radar data from named pipe."""
logger.info("Starting radar pipe acquisition loop", pipe_path=cfg.RADAR_PIPE_PATH)
while self._running and not self._stop_event.is_set():
try:
# Honor pause
if self._paused:
time.sleep(0.1)
continue
# Open named pipe for reading
pipe_path = Path(cfg.RADAR_PIPE_PATH)
# Check if pipe exists
if not pipe_path.exists():
logger.warning("Radar pipe not found, waiting...", path=str(pipe_path))
time.sleep(1.0)
continue
# Open pipe in non-blocking mode to avoid hanging
with open(pipe_path, "rb") as pipe:
# Read data from pipe (adjust buffer size as needed)
data = pipe.read(cfg.EXPECTED_POINTS_PER_SWEEP * cfg.RADAR_BYTES_PER_SAMPLE)
if not data:
time.sleep(0.01)
continue
# Parse radar data
points = self._parse_radar_data(data)
if points:
timestamp = time.time()
sweep_number = self._sweep_buffer.add_sweep(points, timestamp=timestamp)
logger.info(
"Radar sweep collected from pipe",
sweep_number=sweep_number,
points=len(points),
timestamp=timestamp
)
# # Play sweep notification sound
# self._sound_player.play()
# Handle single-sweep mode transitions
if not self._continuous_mode:
if self._single_sweep_requested:
self._single_sweep_requested = False
logger.info("Single radar sweep completed; pausing acquisition")
self.pause()
else:
self.pause()
except FileNotFoundError:
logger.warning("Radar pipe not found, retrying...", pipe_path=cfg.RADAR_PIPE_PATH)
time.sleep(1.0)
except Exception as exc: # noqa: BLE001
logger.error("Radar pipe acquisition loop error", error=repr(exc))
time.sleep(1.0)
def _simulator_acquisition_loop(self) -> None: def _simulator_acquisition_loop(self) -> None:
"""Simplified acquisition loop for simulator mode.""" """Simplified acquisition loop for simulator mode."""
logger.info("Starting simulator acquisition loop") logger.info("Starting simulator acquisition loop")
@ -236,6 +298,11 @@ class VNADataAcquisition:
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."""
# Use radar pipe loop if enabled
if cfg.USE_RADAR_PIPE:
self._radar_pipe_acquisition_loop()
return
# Use simulator loop if simulator is enabled # Use simulator loop if simulator is enabled
if self._simulator is not None: if self._simulator is not None:
self._simulator_acquisition_loop() self._simulator_acquisition_loop()
@ -468,6 +535,74 @@ class VNADataAcquisition:
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
# Parsing & detection # Parsing & detection
# --------------------------------------------------------------------- # # --------------------------------------------------------------------- #
def _parse_radar_data(self, data: bytes) -> list[tuple[float, float]]:
"""
Parse radar data from named pipe format.
Expected format: 4 bytes per 32-bit word (big-endian)
- Word format: 0xF0XXXXXX where:
- F0 is the marker byte (bits 31-24)
- XXXXXX is the 24-bit data value (bits 23-0)
Returns list of (real, imag) tuples where:
- real: dB value converted from raw data
- imag: always 0.0
"""
points: list[tuple[float, float]] = []
# Process data in 4-byte chunks as 32-bit words (big-endian)
num_words = len(data) // cfg.RADAR_BYTES_PER_SAMPLE
for i in range(num_words):
offset = i * cfg.RADAR_BYTES_PER_SAMPLE
chunk = data[offset : offset + cfg.RADAR_BYTES_PER_SAMPLE]
# Unpack as big-endian 32-bit unsigned integer
word = struct.unpack("<I", chunk)[0]
# Extract marker (top 8 bits)
marker = (word >> 24) & 0xFF
# Extract 24-bit data value (lower 24 bits)
raw_value = word & 0xFFFFFF
# Check marker byte - log warning but continue processing
if marker != cfg.RADAR_DATA_MARKER:
# Only log occasionally to avoid spam
if i % 100 == 0:
logger.debug(
"Non-F0 marker detected",
marker=hex(marker),
word=hex(word),
offset=offset
)
# Still process the data if it has valid bits
# Convert unsigned 24-bit to signed 24-bit (as in main.py to_int24)
# If MSB is set, treat as negative (two's complement)
if raw_value & 0x800000:
raw_value -= 0x1000000 # Convert to signed: -8388608 to +8388607
points.append((float(raw_value), 0.))
if i == 0 or i == 100:
logger.debug(f"raw_value: {raw_value}, marker: {marker}, word= {word}" )
# Log statistics about parsed data
if points:
values = [p[0] for p in points]
logger.info(
"📊 Radar data parsed from pipe",
total_points=len(points),
min_value=min(values),
max_value=max(values),
mean_value=sum(values) / len(values) if values else 0,
non_zero=sum(1 for v in values if v != 0)
)
return points
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:

View File

@ -35,10 +35,18 @@ VNA_PID = 0x5740 # STM32 Virtual ComPort
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Simulator mode settings # Simulator mode settings
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
USE_SIMULATOR = True # Set to True to use simulator instead of real device USE_SIMULATOR = False # Set to True to use simulator instead of real device
SIMULATOR_SWEEP_FILE = BASE_DIR / "binary_input" / "sweep_example" / "example.json" SIMULATOR_SWEEP_FILE = BASE_DIR / "binary_input" / "sweep_example" / "example.json"
SIMULATOR_NOISE_LEVEL = 100 # Standard deviation of Gaussian noise to add to real and imaginary parts SIMULATOR_NOISE_LEVEL = 100 # Standard deviation of Gaussian noise to add to real and imaginary parts
# -----------------------------------------------------------------------------
# Radar pipe mode settings
# -----------------------------------------------------------------------------
USE_RADAR_PIPE = True # Set to True to read radar data from named pipe
RADAR_PIPE_PATH = "/tmp/radar_data_pipe" # Path to the named pipe
RADAR_DATA_MARKER = 0xF0 # First byte marker for radar data packets
RADAR_BYTES_PER_SAMPLE = 4 # Total bytes per radar sample (1 marker + 3 data)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Sweep detection and parsing constants # Sweep detection and parsing constants
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@ -46,7 +54,7 @@ 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 = 1024
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Buffer settings # Buffer settings

View File

@ -0,0 +1,3 @@
from .laser_controller import LaserController
__all__ = ['LaserController']

View File

@ -0,0 +1,606 @@
import logging
import time
import json
from pathlib import Path
from typing import Optional, Dict, Any
from datetime import datetime
from ...api.models.laser import LaserParameters, LaserStatus
from .RFG_PCB_PC_controller_supersimple import device_interaction as dev
from .RFG_PCB_PC_controller_supersimple import device_commands as cmd
logger = logging.getLogger(__name__)
class LaserController:
"""
Controller for laser control system.
Uses RFG_PCB_PC_controller_supersimple module for device communication.
Communicates with RadioPhotonic board via serial port (115200 baud).
Supports both manual control and automated scanning modes.
Control Logic (RFG implementation):
- Manual mode: send_control_parameters() to set steady current/temperature
- Scan mode: send_control_parameters() THEN send_task_command() for scanning
- Stop: send_control_parameters() to return to steady state (not reset)
TaskType format: String-based ("TT_CHANGE_CURR_1", "TT_CHANGE_CURR_2")
"""
def __init__(self, config_path: Optional[str] = None):
self.prt = None # Serial port object
self.is_connected = False
self.is_running = False
self.current_parameters: Optional[LaserParameters] = None
self.current_status = LaserStatus()
self.last_data: Optional[Dict[str, Any]] = None
# Load default parameters from JSON
self._load_default_parameters(config_path)
logger.info("LaserController initialized")
def _load_default_parameters(self, config_path: Optional[str] = None) -> None:
"""
Load default parameters from JSON configuration file.
Args:
config_path: Path to JSON config file. If None, uses default location.
"""
try:
# Determine config file path
if config_path is None:
# Default path relative to this file
current_dir = Path(__file__).parent
config_path = current_dir / "RadioPhotonic_PCB_PC_software" / "init_params.json"
else:
config_path = Path(config_path)
# Load JSON file
if not config_path.exists():
logger.warning(f"Config file not found: {config_path}, using hardcoded defaults")
self._set_hardcoded_defaults()
return
with open(config_path, 'r') as f:
config = json.load(f)
# Set parameters from config
self.initial_temperature_1 = config.get("INITIAL_TEMPERATURE_1", 28)
self.initial_temperature_2 = config.get("INITIAL_TEMPERATURE_2", 28.9)
self.initial_current_1 = config.get("INITIAL_CURRENT_1", 33)
self.initial_current_2 = config.get("INITIAL_CURRENT_2", 35)
# PI coefficients (multiplied by 256 as per device protocol)
self.proportional_coeff_1 = int(config.get("PROPORTIONAL_COEFF_1", 10) * 256)
self.proportional_coeff_2 = int(config.get("PROPORTIONAL_COEFF_2", 10) * 256)
self.integral_coeff_1 = int(config.get("INTEGRAL_COEFF_1", 0.5) * 256)
self.integral_coeff_2 = int(config.get("INTEGRAL_COEFF_2", 0.5) * 256)
self.message_id = config.get("MESSAGE_ID", "00FF")
# Additional parameters
self.gui_timeout_interval = config.get("GUI_TIMEOUT_INTERVAL", 5)
self.save_points_number = config.get("SAVE_POINTS_NUMBER", 1000)
logger.info(f"Default parameters loaded from {config_path}")
logger.info(f" Initial T1: {self.initial_temperature_1}°C, T2: {self.initial_temperature_2}°C")
logger.info(f" Initial I1: {self.initial_current_1} mA, I2: {self.initial_current_2} mA")
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in config file: {e}")
self._set_hardcoded_defaults()
except Exception as e:
logger.error(f"Error loading config file: {e}")
self._set_hardcoded_defaults()
def _set_hardcoded_defaults(self) -> None:
"""Set hardcoded default parameters as fallback."""
self.initial_temperature_1 = 28
self.initial_temperature_2 = 28.9
self.initial_current_1 = 33
self.initial_current_2 = 35
self.proportional_coeff_1 = int(10 * 256)
self.proportional_coeff_2 = int(10 * 256)
self.integral_coeff_1 = int(0.5 * 256)
self.integral_coeff_2 = int(0.5 * 256)
self.message_id = "00FF"
self.gui_timeout_interval = 5
self.save_points_number = 1000
logger.info("Using hardcoded default parameters")
def start_cycle(self, parameters: LaserParameters) -> Dict[str, Any]:
"""
Start laser control cycle with given parameters.
Determines mode (manual vs scan) based on enable flags:
- Manual mode: all enable_* flags are False
- Scan mode: one enable_* flag is True
Args:
parameters: LaserParameters object with control settings
Returns:
Dictionary with success status and message
"""
try:
if not self.is_connected or self.prt is None:
return {
"success": False,
"message": "Устройство не подключено. Сначала выполните подключение.",
"parameters": None
}
logger.info("=" * 60)
logger.info("LASER CONTROL: START CYCLE")
logger.info(f"Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}")
logger.info("-" * 60)
# Determine operation mode
is_manual = not any([
parameters.enable_t1,
parameters.enable_t2,
parameters.enable_c1,
parameters.enable_c2
])
if is_manual:
logger.info("Mode: MANUAL CONTROL")
result = self._start_manual_mode(parameters)
else:
logger.info("Mode: AUTOMATED SCAN")
result = self._start_scan_mode(parameters)
if result["success"]:
self.current_parameters = parameters
self.is_running = True
self.current_status.is_running = True
logger.info("=" * 60)
return result
except Exception as e:
logger.error(f"Error starting laser cycle: {e}", exc_info=True)
return {
"success": False,
"message": f"Ошибка при запуске цикла: {str(e)}",
"parameters": None
}
def _start_manual_mode(self, parameters: LaserParameters) -> Dict[str, Any]:
"""
Start manual control mode with fixed T1, T2, I1, I2 values.
Uses DECODE_ENABLE (0x1111) command - simplified version from RFG example.
"""
try:
# Prepare control parameters (simplified - direct send)
ctrl_params = {
'Temp_1': parameters.min_temp_1,
'Temp_2': parameters.min_temp_2,
'Iset_1': parameters.min_current_1,
'Iset_2': parameters.min_current_2,
'ProportionalCoeff_1': self.proportional_coeff_1,
'ProportionalCoeff_2': self.proportional_coeff_2,
'IntegralCoeff_1': self.integral_coeff_1,
'IntegralCoeff_2': self.integral_coeff_2,
'Message_ID': self.message_id
}
logger.info(f"Sending manual control parameters:")
logger.info(f" T1: {ctrl_params['Temp_1']}°C, T2: {ctrl_params['Temp_2']}°C")
logger.info(f" I1: {ctrl_params['Iset_1']} mA, I2: {ctrl_params['Iset_2']} mA")
# Send control parameters to device (steady current mode)
dev.send_control_parameters(self.prt, ctrl_params)
logger.info(dev.request_data(self.prt))
return {
"success": True,
"message": "Ручное управление запущено",
"parameters": parameters.model_dump()
}
except Exception as e:
logger.error(f"Error in manual mode: {e}", exc_info=True)
raise
def _start_scan_mode(self, parameters: LaserParameters) -> Dict[str, Any]:
"""
Start automated scan mode - simplified version from RFG example.
RFG flow: send_control_parameters() THEN send_task_command()
This initializes the device before starting the scan.
"""
try:
# Step 1: Start lasers with initial control parameters (from RFG example lines 72-75)
logger.info("Step 1: Starting lasers with control parameters...")
ctrl_params = {
'Temp_1': parameters.min_temp_1,
'Temp_2': parameters.min_temp_2,
'Iset_1': parameters.min_current_1,
'Iset_2': parameters.min_current_2,
'ProportionalCoeff_1': self.proportional_coeff_1,
'ProportionalCoeff_2': self.proportional_coeff_2,
'IntegralCoeff_1': self.integral_coeff_1,
'IntegralCoeff_2': self.integral_coeff_2,
'Message_ID': self.message_id
}
dev.send_control_parameters(self.prt, ctrl_params)
logger.info(dev.request_data(self.prt))
logger.info("Control parameters sent - lasers started")
# Small delay as in RFG example (line 75: sleep(2))
time.sleep(2)
# Step 2: Determine which parameter to scan
if parameters.enable_c1:
task_type = "TT_CHANGE_CURR_1" # RFG uses string format
scan_param = "Current Laser 1"
logger.info(f"Scanning Current Laser 1: {parameters.min_current_1} to {parameters.max_current_1} mA")
elif parameters.enable_c2:
task_type = "TT_CHANGE_CURR_2" # RFG uses string format
scan_param = "Current Laser 2"
logger.info(f"Scanning Current Laser 2: {parameters.min_current_2} to {parameters.max_current_2} mA")
elif parameters.enable_t1:
return {
"success": False,
"message": "Сканирование температуры Laser 1 не поддерживается устройством",
"parameters": None
}
elif parameters.enable_t2:
return {
"success": False,
"message": "Сканирование температуры Laser 2 не поддерживается устройством",
"parameters": None
}
else:
return {
"success": False,
"message": "Не выбран параметр для сканирования",
"parameters": None
}
# Step 3: Build task parameters for scan (from RFG example lines 78-82)
logger.info("Step 2: Switching to current sweep mode...")
task_params = {
'TaskType': task_type,
'Dt': parameters.delta_time ,
'Tau': parameters.tau,
'ProportionalCoeff_1': self.proportional_coeff_1,
'ProportionalCoeff_2': self.proportional_coeff_2,
'IntegralCoeff_1': self.integral_coeff_1,
'IntegralCoeff_2': self.integral_coeff_2
}
# Add scan-specific parameters
if task_type == "TT_CHANGE_CURR_1":
task_params.update({
'MinC1': parameters.min_current_1,
'MaxC1': parameters.max_current_1,
'DeltaC1': parameters.delta_current_1,
'T1': parameters.min_temp_1, # Fixed
'I2': parameters.min_current_2, # Fixed
'T2': parameters.min_temp_2 # Fixed
})
elif task_type == "TT_CHANGE_CURR_2":
task_params.update({
'MinC2': parameters.min_current_2,
'MaxC2': parameters.max_current_2,
'DeltaC2': parameters.delta_current_2,
'T2': parameters.min_temp_2, # Fixed
'I1': parameters.min_current_1, # Fixed
'T1': parameters.min_temp_1 # Fixed
})
# Send task command to device (start current variation)
dev.send_task_command(self.prt, task_params)
# print(dev.request_data(self.prt))
logger.info("Task command sent successfully - scan started")
return {
"success": True,
"message": f"Сканирование запущено: {scan_param}",
"parameters": parameters.model_dump()
}
except Exception as e:
logger.error(f"Error in scan mode: {e}", exc_info=True)
raise
def stop_cycle(self) -> Dict[str, Any]:
"""
Stop current laser control cycle.
Uses send_control_parameters to return to steady current mode (RFG example logic).
This stops any ongoing scan and maintains the last known parameters.
Returns:
Dictionary with success status and message
"""
try:
logger.info("=" * 60)
logger.info("LASER CONTROL: STOP CYCLE")
logger.info(f"Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}")
logger.info("=" * 60)
if self.is_connected and self.prt is not None:
# Stop current variation - go to steady current mode
# Use last known parameters or defaults
if self.current_parameters:
ctrl_params = {
'Temp_1': self.current_parameters.min_temp_1,
'Temp_2': self.current_parameters.min_temp_2,
'Iset_1': self.current_parameters.min_current_1,
'Iset_2': self.current_parameters.min_current_2,
'ProportionalCoeff_1': self.proportional_coeff_1,
'ProportionalCoeff_2': self.proportional_coeff_2,
'IntegralCoeff_1': self.integral_coeff_1,
'IntegralCoeff_2': self.integral_coeff_2,
'Message_ID': self.message_id
}
logger.info(f"Stopping scan - returning to steady current mode:")
logger.info(f" T1: {ctrl_params['Temp_1']}°C, T2: {ctrl_params['Temp_2']}°C")
logger.info(f" I1: {ctrl_params['Iset_1']} mA, I2: {ctrl_params['Iset_2']} mA")
try:
dev.send_control_parameters(self.prt, ctrl_params)
logger.info(dev.request_data(self.prt))
logger.info("Control parameters sent - laser in steady state")
except Exception as e:
logger.warning(f"Failed to send control parameters: {e}")
else:
logger.warning("No current parameters stored, cannot send steady state command")
self.is_running = False
self.current_status.is_running = False
return {
"success": True,
"message": "Цикл управления лазером остановлен (steady current mode)"
}
except Exception as e:
logger.error(f"Error stopping laser cycle: {e}", exc_info=True)
return {
"success": False,
"message": f"Ошибка при остановке цикла: {str(e)}"
}
def start_manual_direct(self, t1: float, t2: float, i1: float, i2: float) -> Dict[str, Any]:
"""
Start manual control mode with direct T1, T2, I1, I2 parameters.
Simplified interface for manual control.
Args:
t1: Temperature for Laser 1 in Celsius
t2: Temperature for Laser 2 in Celsius
i1: Current for Laser 1 in mA
i2: Current for Laser 2 in mA
Returns:
Dictionary with success status and message
"""
try:
# Check connection status
if not self.is_connected:
logger.error("Device not connected")
return {
"success": False,
"message": "Устройство не подключено. Нажмите кнопку 'Подключить' в настройках.",
}
if self.prt is None:
logger.error("Serial port is None")
return {
"success": False,
"message": "Порт не инициализирован. Переподключите устройство.",
}
# Check if port is actually open
if not self.prt.is_open:
logger.error("Serial port is not open")
return {
"success": False,
"message": "Порт не открыт. Переподключите устройство.",
}
logger.info("=" * 60)
logger.info("LASER CONTROL: START MANUAL MODE (Direct)")
logger.info(f"Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}")
logger.info(f"Port: {self.prt.port}, Open: {self.prt.is_open}")
logger.info(f" T1: {t1}°C, T2: {t2}°C")
logger.info(f" I1: {i1} mA, I2: {i2} mA")
logger.info("=" * 60)
# Prepare control parameters
params = {
'Temp_1': t1,
'Temp_2': t2,
'Iset_1': i1,
'Iset_2': i2,
'ProportionalCoeff_1': self.proportional_coeff_1,
'ProportionalCoeff_2': self.proportional_coeff_2,
'IntegralCoeff_1': self.integral_coeff_1,
'IntegralCoeff_2': self.integral_coeff_2,
'Message_ID': self.message_id
}
# Send control parameters to device
logger.info("Sending control parameters to device...")
dev.send_control_parameters(self.prt, params)
logger.info("Control parameters sent successfully")
self.is_running = True
self.current_status.is_running = True
return {
"success": True,
"message": "Ручное управление запущено",
}
except Exception as e:
logger.error(f"Error starting manual control: {e}", exc_info=True)
# Reset connection status if error
self.is_connected = False
return {
"success": False,
"message": f"Ошибка при запуске: {str(e)}. Попробуйте переподключить устройство.",
}
def get_status(self) -> LaserStatus:
"""
Get current laser status by requesting data from device.
Uses TRANS_ENABLE (0x4444) command.
Returns:
LaserStatus object with current state
"""
self.current_status.connected = self.is_connected
self.current_status.is_running = self.is_running
if self.is_connected and self.prt is not None:
try:
# Request data from device
data = dev.request_data(self.prt)
if data and isinstance(data, dict):
# Update status from device data
self.last_data = data
self.current_status.temp_1 = data.get('Temp_1', 0.0)
self.current_status.temp_2 = data.get('Temp_2', 0.0)
self.current_status.current_1 = data.get('I1', 0.0)
self.current_status.current_2 = data.get('I2', 0.0)
self.current_status.temp_ext_1 = data.get('Temp_Ext_1', 0.0)
self.current_status.temp_ext_2 = data.get('Temp_Ext_2', 0.0)
self.current_status.voltage_3v3 = data.get('MON_3V3', 0.0)
self.current_status.voltage_5v1 = data.get('MON_5V1', 0.0)
self.current_status.voltage_5v2 = data.get('MON_5V2', 0.0)
self.current_status.voltage_7v0 = data.get('MON_7V0', 0.0)
except Exception as e:
logger.warning(f"Error requesting status from device: {e}")
# Keep previous status values on error
return self.current_status
def connect(self, port: Optional[str] = None) -> Dict[str, Any]:
"""
Connect to laser control hardware via serial port.
Auto-detects USB serial ports if port not specified.
Args:
port: Serial port to connect to (e.g., '/dev/ttyUSB0')
If None, will auto-detect USB ports
Returns:
Dictionary with success status and message
"""
try:
logger.info(f"Attempting to connect to laser hardware on port: {port or 'auto-detect'}")
if self.is_connected:
logger.warning("Already connected to device")
return {
"success": True,
"message": "Уже подключено к устройству",
"port": str(self.prt.port) if self.prt else "unknown"
}
# Create port connection (auto-detect if port not specified)
if port:
# Manual port specification
try:
self.prt = cmd.setup_port_connection(port=port, baudrate=115200, timeout_sec=1)
cmd.open_port(self.prt)
dev.reset_port_settings(self.prt)
except Exception as e:
logger.error(f"Failed to connect to specified port {port}: {e}")
return {
"success": False,
"message": f"Не удалось подключиться к порту {port}: {str(e)}",
"port": port
}
else:
# Auto-detect USB ports
self.prt = dev.create_port_connection()
if self.prt is None:
logger.error("Failed to create port connection")
return {
"success": False,
"message": "Не удалось найти устройство. Проверьте подключение USB.",
"port": None
}
self.is_connected = True
self.current_status.connected = True
port_name = self.prt.port if hasattr(self.prt, 'port') else "unknown"
logger.info(f"Successfully connected to laser hardware on {port_name}")
# Request initial status
try:
time.sleep(0.2) # Give device time to initialize
self.get_status()
except Exception as e:
logger.warning(f"Failed to get initial status: {e}")
return {
"success": True,
"message": f"Подключено к устройству на порту {port_name}",
"port": port_name
}
except Exception as e:
logger.error(f"Error connecting to laser hardware: {e}", exc_info=True)
self.is_connected = False
self.prt = None
return {
"success": False,
"message": f"Ошибка подключения: {str(e)}",
"port": port
}
def disconnect(self) -> Dict[str, Any]:
"""
Disconnect from laser control hardware.
Stops any running cycle and closes serial port.
Returns:
Dictionary with success status and message
"""
try:
logger.info("Disconnecting from laser hardware")
# Stop any running cycle first
if self.is_running:
self.stop_cycle()
# Close serial port using RFG close_connection
if self.prt is not None:
try:
dev.close_connection(self.prt)
logger.info("Serial port closed")
except Exception as e:
logger.warning(f"Error closing serial port: {e}")
# Fallback: just set to None
self.prt = None
else:
self.prt = None
self.is_connected = False
self.current_status.connected = False
self.last_data = None
logger.info("Successfully disconnected from laser hardware")
return {
"success": True,
"message": "Отключено от устройства"
}
except Exception as e:
logger.error(f"Error disconnecting from laser hardware: {e}", exc_info=True)
return {
"success": False,
"message": f"Ошибка отключения: {str(e)}"
}

View File

@ -1,11 +1,16 @@
{ {
"open_air": false, "open_air": false,
"axis": "abs", "axis": "abs",
"cut": 0.244, "cut": 0.23,
"max": 1.0, "max": 1.5,
"gain": 1.0, "gain": 1.0,
"start_freq": 600.0, "start_freq": 470.0,
"stop_freq": 6080.0, "stop_freq": 8800.0,
"clear_history": false, "clear_history": false,
"sigma": 1.44,
"border_border_m": 0.41,
"if_normalize": false,
"if_draw_level": false,
"detection_level": 3.0,
"data_limit": 500 "data_limit": 500
} }

View File

@ -1,8 +1,8 @@
{ {
"y_min": -50, "y_min": -80,
"y_max": 40, "y_max": 40,
"autoscale": true, "autoscale": false,
"show_magnitude": true, "show_magnitude": true,
"show_phase": true, "show_phase": false,
"open_air": false "open_air": false
} }

View File

@ -0,0 +1,19 @@
{
"data_type": "SYNC_DET",
"pont_in_one_fq_change": 86,
"gaussian_sigma": 5.0,
"fft0_delta": 5,
"standard_raw_size": 64000,
"standard_sync_size": 1000,
"freq_start_ghz": 3.0,
"freq_stop_ghz": 13.67,
"fq_end": 512,
"show_processed": true,
"show_fourier": true,
"normalize_signal": false,
"log_scale": false,
"disable_interpolation": false,
"y_max_processed": 900000.0,
"y_max_fourier": 1000.0,
"auto_scale": false
}

View File

@ -0,0 +1,197 @@
# RFG Processor - Радиофотонный радар
## Описание
RFGProcessor - новый процессор для обработки данных радиофотонного радара, основанный на алгоритмах из `RFG_Receiver_GUI/main.py`.
## Основные возможности
### Типы данных
- **SYNC_DET (0xF0)** - Данные синхронного детектирования (~1000 сэмплов)
- **RAW (0xD0)** - Сырые ADC данные с меандровой модуляцией (~64000 сэмплов)
### Обработка данных
#### Для SYNC_DET (по умолчанию):
1. Интерполяция до 1000 точек
2. Прямая FFT обработка (данные уже демодулированы)
3. Гауссово сглаживание
4. Обнуление центра FFT
#### Для RAW:
1. Интерполяция до 64000 точек
2. Генерация меандрового сигнала
3. Синхронное детектирование (умножение на меандр)
4. Частотная сегментация (по 86 точек)
5. FFT обработка с гауссовым сглаживанием
6. Обнуление центра FFT
### Выходные графики
1. **Processed Signal** - Обработанный сигнал в частотной области (3-13.67 ГГц)
2. **Fourier Transform** - Спектр FFT с гауссовым сглаживанием
## Конфигурация
### Файл: `configs/rfg_config.json`
```json
{
"data_type": "SYNC_DET", // Тип данных: "RAW" или "SYNC_DET"
"pont_in_one_fq_change": 86, // Размер сегмента частоты
"gaussian_sigma": 5.0, // Параметр сглаживания (σ)
"fft0_delta": 5, // Смещение обнуления центра FFT
"standard_raw_size": 64000, // Целевой размер для RAW
"standard_sync_size": 1000, // Целевой размер для SYNC_DET
"freq_start_ghz": 3.0, // Начало частотного диапазона
"freq_stop_ghz": 13.67, // Конец частотного диапазона
"fq_end": 512, // Точка отсечки FFT
"show_processed": true, // Показать Processed Signal
"show_fourier": true // Показать Fourier Transform
}
```
### UI параметры
Процессор предоставляет следующие настройки через веб-интерфейс:
- **Тип данных** - Выбор между RAW и SYNC_DET
- **Гауссово сглаживание (σ)** - Слайдер 0.1-20.0
- **Размер сегмента частоты** - Слайдер 50-200
- **Смещение центра FFT** - Слайдер 0-20
- **Точка отсечки FFT** - Слайдер 100-1000
- **Частота начала (ГГц)** - Слайдер 1.0-15.0
- **Частота конца (ГГц)** - Слайдер 1.0-15.0
- **Переключатели** - Показать/скрыть графики
## Использование
### Автоматическая регистрация
Процессор автоматически регистрируется в `ProcessorManager` при запуске системы.
### Входные данные
Процессор ожидает данные из pipe в формате `SweepData`:
- `points`: список пар `[(real, imag), ...]`
- Для ADC данных используется только `real` часть
- Данные должны соответствовать формату 0xF0 (SYNC_DET) или 0xD0 (RAW)
### Пример структуры данных
```python
sweep_data = SweepData(
sweep_number=1,
timestamp=1234567890.0,
points=[(adc_sample_1, 0), (adc_sample_2, 0), ...],
total_points=1000 # или 64000 для RAW
)
```
## Алгоритмы обработки
### 1. Интерполяция данных
```python
# Линейная интерполяция scipy.interpolate.interp1d
old_indices = np.linspace(0, 1, len(data))
new_indices = np.linspace(0, 1, target_size)
```
### 2. Меандровая демодуляция (только RAW)
```python
# Генерация квадратного сигнала
time_idx = np.arange(1, size + 1)
meander = square(time_idx * np.pi)
demodulated = data * meander
```
### 3. Частотная сегментация (только RAW)
```python
# Разделение на сегменты и суммирование
segment_size = 86
for segment in segments:
signal.append(np.sum(segment))
```
### 4. FFT обработка
```python
# Обрезка + FFT + сдвиг
sig_cut = np.sqrt(np.abs(signal[:512]))
F = np.fft.fft(sig_cut)
Fshift = np.abs(np.fft.fftshift(F))
# Обнуление центра
center = len(sig_cut) // 2
Fshift[center:center+1] = 0
# Гауссово сглаживание
FshiftS = gaussian_filter1d(Fshift, sigma=5.0)
```
## Отличия от оригинального main.py
### Изменено:
1. **Вход данных**: Вместо CSV файлов - pipe через `SweepData`
2. **Без накопления**: Обработка каждой развёртки отдельно (убран `PeriodIntegrate=2`)
3. **Без B-scan**: Только Processed Signal + Fourier Transform
4. **Plotly вместо Matplotlib**: Веб-визуализация
5. **JSON конфигурация**: Вместо хардкода констант
6. **Один sweep**: Нет потокового чтения файлов
### Сохранено:
- ✅ Алгоритмы обработки (интерполяция, меандр, сегментация, FFT)
- ✅ Гауссово сглаживание
- ✅ Обнуление центра FFT
- ✅ Частотный диапазон 3-13.67 ГГц
- ✅ Параметры обработки (sigma=5, segment=86, delta=5)
## Файлы
```
vna_system/core/processors/
├── implementations/
│ ├── rfg_processor.py # Основной класс
│ └── RFG_PROCESSOR_README.md # Эта документация
├── configs/
│ └── rfg_config.json # Конфигурация по умолчанию
└── manager.py # Регистрация процессора
```
## Разработка и отладка
### Логирование
Процессор использует стандартную систему логирования:
```python
from vna_system.core.logging.logger import get_component_logger
logger = get_component_logger(__file__)
```
### Тестирование
Для тестирования создайте `SweepData` с тестовыми ADC данными:
```python
# Тест SYNC_DET (1000 точек)
test_data = [(float(i), 0.0) for i in range(1000)]
# Тест RAW (64000 точек)
test_data = [(float(i), 0.0) for i in range(64000)]
```
## Зависимости
- `numpy` - Численные операции
- `scipy.signal.square` - Генерация меандра
- `scipy.ndimage.gaussian_filter1d` - Гауссово сглаживание
- `scipy.interpolate.interp1d` - Интерполяция
- `plotly` - Визуализация
## Авторство
Создан на основе алгоритмов из `/home/awe/Documents/RFG_Receiver_GUI/main.py`
Адаптирован для VNA System architecture.
## Версия
v1.0 - Первая реализация (2025-11-28)
- Поддержка SYNC_DET и RAW форматов
- Два графика: Processed Signal + Fourier Transform
- Полная интеграция с VNA System

View File

@ -6,6 +6,7 @@ from typing import Any
import numpy as np import numpy as np
from numpy.typing import NDArray from numpy.typing import NDArray
from scipy.ndimage import gaussian_filter1d
from vna_system.core.logging.logger import get_component_logger from vna_system.core.logging.logger import get_component_logger
from vna_system.core.processors.base_processor import BaseProcessor, UIParameter, ProcessedResult from vna_system.core.processors.base_processor import BaseProcessor, UIParameter, ProcessedResult
@ -56,6 +57,11 @@ class BScanProcessor(BaseProcessor):
"start_freq": 100.0, # Start frequency (MHz) "start_freq": 100.0, # Start frequency (MHz)
"stop_freq": 8800.0, # Stop frequency (MHz) "stop_freq": 8800.0, # Stop frequency (MHz)
"clear_history": False, # UI button; not persisted "clear_history": False, # UI button; not persisted
"sigma" : 0.01,
"border_border_m" : 0.5,
"if_normalize" : False,
"if_draw_level" : False,
"detection_level" : 5,
} }
def get_ui_parameters(self) -> list[UIParameter]: def get_ui_parameters(self) -> list[UIParameter]:
@ -74,7 +80,7 @@ class BScanProcessor(BaseProcessor):
label="Ось", label="Ось",
type="select", type="select",
value=cfg["axis"], value=cfg["axis"],
options={"choices": ["real", "abs", "phase"]}, options={"choices": ["real", "imag", "abs", "phase"]},
), ),
# UIParameter( # UIParameter(
# name="data_limitation", # name="data_limitation",
@ -118,6 +124,43 @@ class BScanProcessor(BaseProcessor):
value=cfg["stop_freq"], value=cfg["stop_freq"],
options={"min": 100.0, "max": 8800.0, "step": 10.0, "dtype": "float"}, options={"min": 100.0, "max": 8800.0, "step": 10.0, "dtype": "float"},
), ),
# Modern features
UIParameter(
name="sigma",
label="Степень сглаживания в abs режиме",
type="slider",
value=0.01,
options={"min": 0.01, "max": 5.0, "step": 0.01, "dtype": "float"},
),
UIParameter(
name="if_normalize",
label="Нормировка",
type="toggle",
value=False,
),
UIParameter(
name="border_border_m",
label="Глубина границы",
type="slider",
value=0.5,
options={"min": 0.0, "max": 2.5, "step": 0.01, "dtype": "float"},
),
UIParameter(
name="if_draw_level",
label="Детекция",
type="toggle",
value=False,
),
UIParameter(
name="detection_level",
label="Порог детекции,%",
type="slider",
value=5,
options={"min": 0.0, "max": 100.0, "step": 0.1, "dtype": "float"},
),
# Big botton
UIParameter( UIParameter(
name="clear_history", name="clear_history",
label="Очистить историю", label="Очистить историю",
@ -291,112 +334,167 @@ class BScanProcessor(BaseProcessor):
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def generate_plotly_config( def generate_plotly_config(
self, self,
processed_data: dict[str, Any], processed_data: dict[str, Any],
vna_config: dict[str, Any], # noqa: ARG002 - reserved for future layout tweaks vna_config: dict[str, Any], # noqa: ARG002 - reserved for future layout tweaks
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Produce a Plotly-compatible heatmap configuration from accumulated sweeps. Produce a Plotly-compatible heatmap configuration from accumulated sweeps.
""" """
if "error" in processed_data: if "error" in processed_data:
return { return {
"data": [], "data": [],
"layout": { "layout": {
"title": "B-Scan анализ - Ошибка", "title": "B-Scan анализ - Ошибка",
"annotations": [ "annotations": [
{ {
"text": f"Ошибка: {processed_data['error']}", "text": f"Ошибка: {processed_data['error']}",
"x": 0.5, "x": 0.5,
"y": 0.5, "y": 0.5,
"xref": "paper", "xref": "paper",
"yref": "paper", "yref": "paper",
"showarrow": False, "showarrow": False,
} }
], ],
"template": "plotly_dark", "template": "plotly_dark",
}, },
}
with self._lock:
history = list(self._plot_history)
if not history:
return {
"data": [],
"layout": {
"title": "B-Scan анализ - Нет данных",
"xaxis": {"title": "Номер развертки"},
"yaxis": {"title": "Глубина (м)"},
"template": "plotly_dark",
},
}
Y_VALUE = self._config["border_border_m"]
# Build scatter-like heatmap (irregular grid) from history
x_coords: list[int] = []
y_coords: list[float] = []
z_values: list[float] = []
z_values_square = np.zeros((len(history[0]["distance_data"]),len(history)),dtype=float)
for sweep_index, item in enumerate(history, start=1):
depths = item["distance_data"]
amps = item["time_domain_data"]
if self._config['if_normalize']:
depth_mask = np.array(depths) < Y_VALUE
normalized_ampls = np.array(amps) / np.max(np.array(amps)[depth_mask])
else:
normalized_ampls = np.array(amps)
z_values_square[:,sweep_index-1] = normalized_ampls
for d, a in zip(depths, normalized_ampls, strict=False):
x_coords.append(sweep_index)
y_coords.append(d)
z_values.append(float(a))
if self._config["if_draw_level"]:
detection_level_abs = np.percentile(z_values, 100 - self._config["detection_level"])
detected_points_mask = (np.array(z_values) > detection_level_abs) & (np.array(y_coords) > Y_VALUE)
detected_points_x = list([float(x) for x in np.array(x_coords)[detected_points_mask]])
detected_points_y = list([float(y) for y in np.array(y_coords)[detected_points_mask]])
detected_trace = {
"type": "scatter",
"mode": "markers",
"x": detected_points_x,
"y": detected_points_y,
"marker": {
"size": 8,
"color": "red",
"symbol": "circle",
},
"name": "Обнаруженные точки",
}
else:
detected_trace = None
# 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": "Амплитуда"},
"hovertemplate": (
"Развертка: %{x}<br>"
"Глубина: %{y:.3f} м<br>"
"Амплитуда: %{z:.3f}<br>"
"<extra></extra>"
),
**heatmap_kwargs,
} }
with self._lock: freq_start, freq_stop = processed_data.get("frequency_range", [0.0, 0.0])
history = list(self._plot_history) config_info = (
f"Частота: {freq_start/1e6:.1f}-{freq_stop/1e6:.1f} МГц | "
f"Усиление: {self._config['gain']:.1f} | "
f"Отсечка: {self._config['cut']:.3f} м | "
f"Макс глубина: {self._config['max']:.1f} м | "
f"Ось: {self._config['axis']} | "
f"Разверток: {len(history)}"
)
if not history: if processed_data.get("reference_used", False):
return { config_info += " | Открытый воздух: ВКЛ"
"data": [],
"layout": { # if self._config["data_limitation"]:
"title": "B-Scan анализ - Нет данных", # config_info += f" | Limit: {self._config['data_limitation']}"
"xaxis": {"title": "Номер развертки"},
"yaxis": {"title": "Глубина (м)"}, layout = {
"template": "plotly_dark", "title": f"B-Scan тепловая карта - {config_info}",
}, "xaxis": {"title": "Номер развертки", "side": "bottom"},
"yaxis": {"title": "Глубина (м)", "autorange": "reversed"},
"hovermode": "closest",
"height": 546,
"template": "plotly_dark",
"margin": {"t": 40, "r": 50, "b": 110, "l": 50},
"autosize": True,
} }
# Build scatter-like heatmap (irregular grid) from history print(self._config['if_normalize'], self._config['if_draw_level'])
x_coords: list[int] = [] if self._config['if_normalize'] or self._config['if_draw_level']:
y_coords: list[float] = [] layout["shapes"] = layout.get("shapes", []) + [
z_values: list[float] = [] {
"type": "line",
# по X — координаты "бумаги" (01 по всей ширине графика)
"xref": "paper",
# по Y — координаты данных (в метрах глубины)
"yref": "y",
"x0": 0,
"x1": 1,
"y0": Y_VALUE,
"y1": Y_VALUE,
"line": {
"width": 2,
"dash": "dash",
"color": "white",
},
}
]
for sweep_index, item in enumerate(history, start=1): if detected_trace is not None:
depths = item["distance_data"] return {"data": [heatmap_trace,detected_trace], "layout": layout}
amps = item["time_domain_data"] return {"data": [heatmap_trace], "layout": layout}
for d, a in zip(depths, amps, strict=False):
x_coords.append(sweep_index)
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": "Амплитуда"},
"hovertemplate": (
"Развертка: %{x}<br>"
"Глубина: %{y:.3f} м<br>"
"Амплитуда: %{z:.3f}<br>"
"<extra></extra>"
),
**heatmap_kwargs,
}
freq_start, freq_stop = processed_data.get("frequency_range", [0.0, 0.0])
config_info = (
f"Частота: {freq_start/1e6:.1f}-{freq_stop/1e6:.1f} МГц | "
f"Усиление: {self._config['gain']:.1f} | "
f"Отсечка: {self._config['cut']:.3f} м | "
f"Макс глубина: {self._config['max']:.1f} м | "
f"Ось: {self._config['axis']} | "
f"Разверток: {len(history)}"
)
if processed_data.get("reference_used", False):
config_info += " | Открытый воздух: ВКЛ"
# if self._config["data_limitation"]:
# config_info += f" | Limit: {self._config['data_limitation']}"
layout = {
"title": f"B-Scan тепловая карта - {config_info}",
"xaxis": {"title": "Номер развертки", "side": "bottom"},
"yaxis": {"title": "Глубина (м)", "autorange": "reversed"},
"hovermode": "closest",
"height": 546,
"template": "plotly_dark",
"margin": {"t": 40, "r": 50, "b": 110, "l": 50},
"autosize": True
}
return {"data": [heatmap_trace], "layout": layout}
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Recalculation override # Recalculation override
@ -557,6 +655,14 @@ class BScanProcessor(BaseProcessor):
freq_start = self._config["start_freq"] * 1e6 freq_start = self._config["start_freq"] * 1e6
freq_stop = self._config["stop_freq"] * 1e6 freq_stop = self._config["stop_freq"] * 1e6
# Determine sigma for smoothing
if vna_config:
calc_type = self._config["axis"]
sigma = self._config["sigma"]
else:
calc_type = self._config["axis"]
sigma = self._config["sigma"]
# Frequency vector over current data length # Frequency vector over current data length
freq_axis = np.linspace(freq_start, freq_stop, complex_data.size, dtype=float) freq_axis = np.linspace(freq_start, freq_stop, complex_data.size, dtype=float)
@ -571,8 +677,13 @@ class BScanProcessor(BaseProcessor):
# Depth windowing and gain shaping # Depth windowing and gain shaping
depth_out, time_out = self._apply_depth_processing(depth_m, time_response) depth_out, time_out = self._apply_depth_processing(depth_m, time_response)
if calc_type == 'abs':
filtered_time_out = gaussian_filter1d(time_out, sigma=sigma)
else:
filtered_time_out = time_out
return { return {
"time_data": time_out, "time_data": filtered_time_out,
"distance": depth_out, "distance": depth_out,
"freq_range": [freq_start, freq_stop], "freq_range": [freq_start, freq_stop],
"complex_time": complex_data, "complex_time": complex_data,
@ -659,6 +770,8 @@ class BScanProcessor(BaseProcessor):
y_fin = np.abs(y) y_fin = np.abs(y)
elif axis == "real": elif axis == "real":
y_fin = np.real(y) y_fin = np.real(y)
elif axis == "imag":
y_fin = np.imag(y)
elif axis == "phase": elif axis == "phase":
y_fin = np.angle(y) y_fin = np.angle(y)
else: else:

View File

@ -94,7 +94,7 @@ class MagnitudeProcessor(BaseProcessor):
real_points.append(float(real)) real_points.append(float(real))
imag_points.append(float(imag)) imag_points.append(float(imag))
mag = abs(complex_val) mag = abs(complex_val)
mags_db.append(20.0 * np.log10(mag) if mag > 0.0 else -120.0) mags_db.append( 20*np.log10(mag) if mag > 0.0 else -120.0)
phases_deg.append(np.degrees(np.angle(complex_val))) phases_deg.append(np.degrees(np.angle(complex_val)))
result = { result = {

View File

@ -0,0 +1,643 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Any
import numpy as np
from numpy.typing import NDArray
from scipy.signal import square
from scipy.ndimage import gaussian_filter1d
from scipy.interpolate import interp1d
from vna_system.core.logging.logger import get_component_logger
from vna_system.core.processors.base_processor import BaseProcessor, UIParameter, ProcessedResult
from vna_system.core.acquisition.sweep_buffer import SweepData
logger = get_component_logger(__file__)
class RFGProcessor(BaseProcessor):
"""
Radiophotonic radar processor based on RFG_Receiver_GUI algorithm.
Processes ADC data with synchronous detection (0xF0 format) or RAW data (0xD0 format).
Outputs two graphs:
- Processed Signal: Frequency domain signal (3-13.67 GHz range)
- Fourier Transform: FFT magnitude spectrum with Gaussian smoothing
Features
--------
- Data interpolation to standard size
- Meander demodulation (for RAW data)
- Frequency segmentation
- FFT processing with Gaussian smoothing
- Center zeroing for artifact reduction
"""
def __init__(self, config_dir: Path) -> None:
super().__init__("rfg_radar", config_dir)
# No history accumulation needed (process single sweeps)
self._max_history = 1
# Pre-computed meander signal (will be initialized on first use)
self._meandr: NDArray[np.floating] | None = None
self._last_size: int = 0
logger.info("RFGProcessor initialized", processor_id=self.processor_id)
# -------------------------------------------------------------------------
# Configuration
# -------------------------------------------------------------------------
def _get_default_config(self) -> dict[str, Any]:
"""Return default configuration values."""
return {
"data_type": "SYNC_DET", # "RAW" or "SYNC_DET"
"pont_in_one_fq_change": 86, # Frequency segment size
"gaussian_sigma": 5.0, # FFT smoothing sigma
"fft0_delta": 5, # Center zero offset
"standard_raw_size": 64000, # Target size for RAW data
"standard_sync_size": 1000, # Target size for SYNC_DET data
"freq_start_ghz": 3.0, # Display frequency start (GHz)
"freq_stop_ghz": 13.67, # Display frequency stop (GHz)
"fq_end": 512, # FFT cutoff point
"show_processed": True, # Show processed signal graph
"show_fourier": True, # Show Fourier transform graph
"normalize_signal": False, # Normalize processed signal to [0, 1]
"log_scale": False, # Use logarithmic scale for signal
"disable_interpolation": False, # Skip interpolation (use raw data size)
"y_max_processed": 900000.0, # Max Y-axis value for processed signal
"y_max_fourier": 1000.0, # Max Y-axis value for Fourier spectrum
"auto_scale": False, # Auto-scale Y-axis (ignore y_max if True)
}
def get_ui_parameters(self) -> list[UIParameter]:
"""Return UI parameter schema for configuration."""
cfg = self._config
return [
UIParameter(
name="data_type",
label="Тип данных",
type="select",
value=cfg["data_type"],
options={"choices": ["RAW", "SYNC_DET"]},
),
UIParameter(
name="gaussian_sigma",
label="Гауссово сглаживание (σ)",
type="slider",
value=cfg["gaussian_sigma"],
options={"min": 0.1, "max": 20.0, "step": 0.1, "dtype": "float"},
),
UIParameter(
name="pont_in_one_fq_change",
label="Размер сегмента частоты",
type="slider",
value=cfg["pont_in_one_fq_change"],
options={"min": 50, "max": 200, "step": 1, "dtype": "int"},
),
UIParameter(
name="fft0_delta",
label="Смещение центра FFT",
type="slider",
value=cfg["fft0_delta"],
options={"min": 0, "max": 20, "step": 1, "dtype": "int"},
),
UIParameter(
name="fq_end",
label="Точка отсечки FFT",
type="slider",
value=cfg["fq_end"],
options={"min": 100, "max": 1000, "step": 10, "dtype": "int"},
),
UIParameter(
name="freq_start_ghz",
label="Частота начала (ГГц)",
type="slider",
value=cfg["freq_start_ghz"],
options={"min": 1.0, "max": 15.0, "step": 0.1, "dtype": "float"},
),
UIParameter(
name="freq_stop_ghz",
label="Частота конца (ГГц)",
type="slider",
value=cfg["freq_stop_ghz"],
options={"min": 1.0, "max": 15.0, "step": 0.1, "dtype": "float"},
),
UIParameter(
name="show_processed",
label="Показать обработанный сигнал",
type="toggle",
value=cfg["show_processed"],
),
UIParameter(
name="show_fourier",
label="Показать Фурье образ",
type="toggle",
value=cfg["show_fourier"],
),
UIParameter(
name="normalize_signal",
label="Нормализовать сигнал",
type="toggle",
value=cfg["normalize_signal"],
),
UIParameter(
name="log_scale",
label="Логарифмическая шкала",
type="toggle",
value=cfg["log_scale"],
),
UIParameter(
name="disable_interpolation",
label="Без интерполяции (как FOURIER)",
type="toggle",
value=cfg["disable_interpolation"],
),
UIParameter(
name="auto_scale",
label="Автоматический масштаб Y",
type="toggle",
value=cfg["auto_scale"],
),
UIParameter(
name="y_max_processed",
label="Макс. амплитуда (Processed)",
type="slider",
value=cfg["y_max_processed"],
options={"min": 1000.0, "max": 200000.0, "step": 1000.0, "dtype": "float"},
),
UIParameter(
name="y_max_fourier",
label="Макс. амплитуда (Fourier)",
type="slider",
value=cfg["y_max_fourier"],
options={"min": 1000.0, "max": 200000.0, "step": 1000.0, "dtype": "float"},
),
]
# -------------------------------------------------------------------------
# Processing
# -------------------------------------------------------------------------
def process_sweep(
self,
sweep_data: SweepData,
calibrated_data: SweepData | None,
vna_config: dict[str, Any],
) -> dict[str, Any]:
"""
Process a single sweep of ADC data.
Returns
-------
dict
Keys: processed_signal, fourier_spectrum, freq_axis, data_type, points_processed
Or: {"error": "..."} on failure.
"""
try:
# Extract raw ADC data from sweep (use real part only)
adc_data = self._extract_adc_data(sweep_data)
if adc_data is None or adc_data.size == 0:
logger.warning("No valid ADC data for RFG processing")
return {"error": "No valid ADC data"}
data_type = self._config["data_type"]
if data_type == "RAW":
# Full processing with meander demodulation
processed_signal, fourier_spectrum = self._process_raw_data(adc_data)
elif data_type == "SYNC_DET":
# Direct FFT processing (data already demodulated)
processed_signal, fourier_spectrum = self._process_sync_det_data(adc_data)
else:
return {"error": f"Unknown data type: {data_type}"}
# Generate frequency axis for visualization
freq_axis = self._generate_frequency_axis(processed_signal)
fourier_spectrum /= (2*3.14)
return {
"processed_signal": processed_signal.tolist(),
"fourier_spectrum": fourier_spectrum.tolist(),
"freq_axis": freq_axis.tolist(),
"data_type": data_type,
"points_processed": int(adc_data.size),
"original_size": int(adc_data.size),
}
except Exception as exc: # noqa: BLE001
logger.error("RFG 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
) -> dict[str, Any]:
"""
Produce Plotly configuration for two subplots: Processed Signal + Fourier Transform.
"""
if "error" in processed_data:
return {
"data": [],
"layout": {
"title": "RFG Радар - Ошибка",
"annotations": [
{
"text": f"Ошибка: {processed_data['error']}",
"x": 0.5,
"y": 0.5,
"xref": "paper",
"yref": "paper",
"showarrow": False,
}
],
"template": "plotly_dark",
},
}
processed_signal = processed_data.get("processed_signal", [])
fourier_spectrum = processed_data.get("fourier_spectrum", [])
freq_axis = processed_data.get("freq_axis", [])
# Create subplot configuration
traces = []
if self._config["show_processed"] and processed_signal:
# Processed Signal trace - use absolute value as in main.py line 1043
import numpy as np
processed_signal_abs = np.abs(np.array(processed_signal))
# Apply normalization if enabled
if self._config.get("normalize_signal", False):
max_val = np.max(processed_signal_abs)
if max_val > 0:
processed_signal_abs = processed_signal_abs / max_val
# Apply log scale if enabled
if self._config.get("log_scale", False):
processed_signal_abs = np.log10(processed_signal_abs + 1e-12) # Add small epsilon to avoid log(0)
logger.debug(
"Processed signal stats",
min=float(np.min(processed_signal_abs)),
max=float(np.max(processed_signal_abs)),
mean=float(np.mean(processed_signal_abs)),
size=len(processed_signal_abs)
)
traces.append({
"type": "scatter",
"mode": "lines",
"x": freq_axis,
"y": processed_signal_abs.tolist(),
"name": "Обработанный сигнал",
"line": {"width": 1, "color": "cyan"},
"xaxis": "x",
"yaxis": "y",
})
if self._config["show_fourier"] and fourier_spectrum:
# Fourier Transform trace
fft_x = list(range(len(fourier_spectrum)))
traces.append({
"type": "scatter",
"mode": "lines",
"x": fft_x,
"y": fourier_spectrum,
"name": "Фурье образ",
"line": {"width": 1, "color": "yellow"},
"xaxis": "x2",
"yaxis": "y2",
})
# Build layout with two subplots
layout = {
"title": (
f"RFG Радар - {processed_data.get('data_type', 'N/A')} | "
f"Точек: {processed_data.get('points_processed', 0)}"
),
"grid": {"rows": 2, "columns": 1, "pattern": "independent"},
"xaxis": {
"title": "Частота (ГГц)",
"domain": [0, 1],
"anchor": "y",
},
"yaxis": {
"title": "Амплитуда",
"domain": [0.55, 1],
"anchor": "x",
},
"xaxis2": {
"title": "Индекс",
"domain": [0, 1],
"anchor": "y2",
},
"yaxis2": {
"title": "Магнитуда FFT",
"domain": [0, 0.45],
"anchor": "x2",
},
"template": "plotly_dark",
"height": 700,
"showlegend": True,
"hovermode": "closest",
}
# Apply Y-axis limits if not auto-scaling
if not self._config.get("auto_scale", False):
y_max_processed = float(self._config.get("y_max_processed", 90000.0))
y_max_fourier = float(self._config.get("y_max_fourier", 60000.0))
layout["yaxis"]["range"] = [0, y_max_processed]
layout["yaxis2"]["range"] = [0, y_max_fourier]
logger.debug(
"Y-axis limits applied",
processed_max=y_max_processed,
fourier_max=y_max_fourier
)
return {"data": traces, "layout": layout}
# -------------------------------------------------------------------------
# Data Processing Helpers
# -------------------------------------------------------------------------
def _extract_adc_data(self, sweep_data: SweepData) -> NDArray[np.floating] | None:
"""
Extract ADC data from SweepData.
Assumes sweep_data.points contains [(real, imag), ...] pairs.
For ADC data, we take only the real part.
"""
try:
if not sweep_data.points:
return None
# Extract real part (ADC samples)
arr = np.asarray(sweep_data.points, dtype=float)
logger.info(
"🔍 RAW INPUT DATA SHAPE",
shape=arr.shape,
ndim=arr.ndim,
total_points=sweep_data.total_points,
dtype=arr.dtype
)
if arr.ndim == 2 and arr.shape[1] >= 1:
# Take first column (real part)
adc_data = arr[:, 0]
elif arr.ndim == 1:
# Already 1D array
adc_data = arr
else:
raise ValueError("Unexpected data shape for ADC extraction")
logger.info(
"📊 EXTRACTED ADC DATA",
size=adc_data.size,
min=float(np.min(adc_data)),
max=float(np.max(adc_data)),
mean=float(np.mean(adc_data)),
non_zero_count=int(np.count_nonzero(adc_data))
)
return adc_data.astype(float, copy=False)
except Exception as exc: # noqa: BLE001
logger.error("Failed to extract ADC data", error=repr(exc))
return None
def _resize_1d_interpolate(
self,
data: NDArray[np.floating],
target_size: int,
) -> NDArray[np.floating]:
"""
Resize 1D array using linear interpolation.
Based on main.py resize_1d_interpolate() function.
"""
if len(data) == target_size:
return data
old_indices = np.linspace(0, 1, len(data))
new_indices = np.linspace(0, 1, target_size)
f = interp1d(old_indices, data, kind='linear', fill_value='extrapolate')
return f(new_indices)
def _process_raw_data(
self,
adc_data: NDArray[np.floating],
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
"""
Process RAW ADC data (0xD0 format).
Pipeline (based on main.py lines 824-896):
1. Interpolate to standard size (64000)
2. Generate meander signal for demodulation
3. Synchronous detection (multiply by meander)
4. Frequency segmentation
5. FFT processing with Gaussian smoothing
Returns
-------
tuple
(processed_signal, fourier_spectrum)
"""
# Step 1: Resize to standard RAW size
target_size = int(self._config["standard_raw_size"])
data_resized = self._resize_1d_interpolate(adc_data, target_size)
# Step 2: Generate meander signal (square wave)
if self._meandr is None or self._last_size != target_size:
time_idx = np.arange(1, target_size + 1)
self._meandr = square(time_idx * np.pi)
self._last_size = target_size
logger.debug("Meander signal regenerated", size=target_size)
# Step 3: Meander demodulation (synchronous detection)
demodulated = data_resized * self._meandr
# Step 4: Frequency segmentation
processed_signal = self._frequency_segmentation(demodulated)
# Step 5: FFT processing
fourier_spectrum = self._compute_fft_spectrum(processed_signal) / (2*3.14)
return processed_signal, fourier_spectrum
def _process_sync_det_data(
self,
adc_data: NDArray[np.floating],
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
"""
Process SYNC_DET data (0xF0 format).
Pipeline (based on main.py lines 898-917):
1. Interpolate to standard size (1000)
2. Use data directly as signal (already demodulated)
3. FFT processing with Gaussian smoothing
Returns
-------
tuple
(processed_signal, fourier_spectrum)
"""
# Step 1: Optionally resize to standard SYNC_DET size
if self._config.get("disable_interpolation", False):
# FOURIER mode: no interpolation, use raw data size
data_resized = adc_data
logger.info("🚫 Interpolation disabled - using raw data size", size=adc_data.size)
else:
# SYNC_DET mode: interpolate to standard size
target_size = int(self._config["standard_sync_size"])
data_resized = self._resize_1d_interpolate(adc_data, target_size)
logger.debug(
"SYNC_DET data resized",
original_size=adc_data.size,
target_size=target_size,
min_val=float(np.min(data_resized)),
max_val=float(np.max(data_resized)),
mean_val=float(np.mean(data_resized))
)
# Step 2: Data is already demodulated, use directly
processed_signal = data_resized
# Step 3: FFT processing
fourier_spectrum = self._compute_fft_spectrum(processed_signal)
logger.debug(
"SYNC_DET processing complete",
signal_size=processed_signal.size,
fft_size=fourier_spectrum.size
)
return processed_signal, fourier_spectrum
def _frequency_segmentation(
self,
data: NDArray[np.floating],
) -> NDArray[np.floating]:
"""
Divide data into frequency segments and sum each segment.
Based on main.py lines 866-884.
Parameters
----------
data : ndarray
Demodulated signal
Returns
-------
ndarray
Segmented and summed signal
"""
pont_in_one_fq = int(self._config["pont_in_one_fq_change"])
signal_list = []
start = 0
segment_start_idx = 0
for idx in range(len(data)):
if (idx - start) > pont_in_one_fq:
segment = data[segment_start_idx:idx]
if segment.size > 0:
# Sum the segment to extract signal
signal_list.append(np.sum(segment))
start = idx
segment_start_idx = idx
return np.array(signal_list, dtype=float)
def _compute_fft_spectrum(
self,
signal: NDArray[np.floating],
) -> NDArray[np.floating]:
"""
Compute FFT spectrum with Gaussian smoothing.
Based on main.py lines 984-994.
Parameters
----------
signal : ndarray
Input signal (processed or raw)
Returns
-------
ndarray
Smoothed FFT magnitude spectrum
"""
fq_end = int(self._config["fq_end"])
gaussian_sigma = float(self._config["gaussian_sigma"])
fft0_delta = int(self._config["fft0_delta"])
# Cut signal to FFT length
sig_cut = signal[:fq_end] if len(signal) >= fq_end else signal
# Take square root of absolute value (as in main.py)
sig_cut = np.sqrt(np.abs(sig_cut))
# Compute FFT
F = np.fft.fft(sig_cut)
Fshift = np.abs(np.fft.fftshift(F))
# Zero out the center (remove DC component and nearby artifacts)
center = len(sig_cut) // 2
if center < len(Fshift):
zero_start = max(center - 0, 0)
zero_end = min(center + 1, len(Fshift))
Fshift[zero_start:zero_end] = 0
# Apply Gaussian smoothing
FshiftS = gaussian_filter1d(Fshift, gaussian_sigma)
return FshiftS
def _generate_frequency_axis(
self,
signal: NDArray[np.floating],
) -> NDArray[np.floating]:
"""
Generate frequency axis for visualization.
Based on main.py lines 1041-1042: maps signal indices to 3-13.67 GHz range.
Parameters
----------
signal : ndarray
Processed signal
Returns
-------
ndarray
Frequency axis in GHz
"""
freq_start = float(self._config["freq_start_ghz"])
freq_stop = float(self._config["freq_stop_ghz"])
signal_size = signal.size
if signal_size == 0:
return np.array([])
# Calculate frequency per point
freq_range = freq_stop - freq_start
per_point_fq = freq_range / signal_size
# Generate frequency axis: start + (index * per_point_fq)
freq_axis = freq_start + (np.arange(1, signal_size + 1) * per_point_fq)
return freq_axis

View File

@ -397,11 +397,13 @@ class ProcessorManager:
try: try:
from .implementations.magnitude_processor import MagnitudeProcessor from .implementations.magnitude_processor import MagnitudeProcessor
from .implementations.bscan_processor import BScanProcessor from .implementations.bscan_processor import BScanProcessor
from .implementations.rfg_processor import RFGProcessor
# self.register_processor(PhaseProcessor(self.config_dir)) # self.register_processor(PhaseProcessor(self.config_dir))
self.register_processor(BScanProcessor(self.config_dir)) self.register_processor(BScanProcessor(self.config_dir))
self.register_processor(MagnitudeProcessor(self.config_dir)) self.register_processor(MagnitudeProcessor(self.config_dir))
self.register_processor(RFGProcessor(self.config_dir))
# self.register_processor(SmithChartProcessor(self.config_dir)) # self.register_processor(SmithChartProcessor(self.config_dir))
logger.info("Default processors registered", count=len(self._processors)) logger.info("Default processors registered", count=len(self._processors))

View File

@ -11,6 +11,7 @@ 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.laser.laser_controller import LaserController
from vna_system.core.config import PROCESSORS_CONFIG_DIR_PATH from vna_system.core.config import PROCESSORS_CONFIG_DIR_PATH
# Global singleton instances # Global singleton instances
@ -22,4 +23,7 @@ processor_manager: ProcessorManager = ProcessorManager(vna_data_acquisition_inst
data_storage = DataStorage() data_storage = DataStorage()
processor_websocket_handler: ProcessorWebSocketHandler = ProcessorWebSocketHandler( processor_websocket_handler: ProcessorWebSocketHandler = ProcessorWebSocketHandler(
processor_manager, data_storage processor_manager, data_storage
) )
# Laser control system
laser_controller_instance: LaserController = LaserController()

View File

@ -8,7 +8,7 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import vna_system.core.singletons as singletons import vna_system.core.singletons as singletons
from vna_system.api.endpoints import acquisition, health, settings, web_ui from vna_system.api.endpoints import acquisition, health, settings, web_ui, laser
from vna_system.api.websockets import processing as ws_processing from vna_system.api.websockets import processing as ws_processing
from vna_system.core.config import API_HOST, API_PORT from vna_system.core.config import API_HOST, API_PORT
from vna_system.core.logging.logger import get_component_logger, setup_logging from vna_system.core.logging.logger import get_component_logger, setup_logging
@ -40,6 +40,17 @@ async def lifespan(app: FastAPI):
processors=singletons.processor_manager.list_processors(), processors=singletons.processor_manager.list_processors(),
) )
# Try to connect to laser controller (optional, non-blocking)
logger.info("Attempting to connect to laser control hardware")
try:
result = singletons.laser_controller_instance.connect()
if result["success"]:
logger.info("Laser controller connected", port=result.get("port"))
else:
logger.warning("Laser controller connection failed (will retry on demand)", message=result.get("message"))
except Exception as e:
logger.warning("Failed to connect to laser controller on startup (will retry on demand)", error=str(e))
logger.info("VNA API Server started successfully") logger.info("VNA API Server started successfully")
yield yield
except Exception as exc: except Exception as exc:
@ -47,6 +58,14 @@ async def lifespan(app: FastAPI):
raise raise
logger.info("Shutting down VNA API Server") logger.info("Shutting down VNA API Server")
# Disconnect laser controller
if singletons.laser_controller_instance and singletons.laser_controller_instance.is_connected:
try:
singletons.laser_controller_instance.disconnect()
logger.info("Laser controller disconnected")
except Exception as e:
logger.warning("Error disconnecting laser controller", error=str(e))
if singletons.processor_manager: if singletons.processor_manager:
singletons.processor_manager.stop_processing() singletons.processor_manager.stop_processing()
logger.info("Processor system stopped") logger.info("Processor system stopped")
@ -77,6 +96,7 @@ app.include_router(web_ui.router)
app.include_router(health.router) app.include_router(health.router)
app.include_router(acquisition.router) app.include_router(acquisition.router)
app.include_router(settings.router) app.include_router(settings.router)
app.include_router(laser.router)
app.include_router(ws_processing.router) app.include_router(ws_processing.router)

View File

@ -574,3 +574,67 @@
min-width: unset; min-width: unset;
} }
} }
/* ========================================
Laser Parameters Table Styles
======================================== */
.laser-params-table {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-3);
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.laser-param-cell {
display: flex;
align-items: center;
justify-content: center;
}
.laser-param-header {
color: var(--text-secondary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
text-align: center;
padding: var(--space-3);
background: var(--bg-surface);
border-radius: var(--radius-md);
border: 1px solid var(--border-primary);
}
.laser-param-input {
display: flex;
align-items: stretch;
}
.laser-param-input .settings-input {
width: 100%;
text-align: center;
font-family: var(--font-mono);
font-weight: var(--font-weight-medium);
}
/* Responsive layout for smaller screens */
@media (max-width: 1024px) {
.laser-params-table {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.laser-params-table {
grid-template-columns: 1fr;
}
.laser-param-header {
text-align: left;
}
.laser-param-input .settings-input {
text-align: left;
}
}

View File

@ -3,7 +3,7 @@
* Handles Plotly.js chart creation, updates, and management * Handles Plotly.js chart creation, updates, and management
*/ */
import { formatProcessorName, safeClone, downloadJSON } from './utils.js'; import { formatProcessorName, safeClone, downloadJSON, showConfirmDialog } from './utils.js';
import { renderIcons } from './icons.js'; import { renderIcons } from './icons.js';
import { ChartSettingsManager } from './charts/chart-settings.js'; import { ChartSettingsManager } from './charts/chart-settings.js';
import { BScanClickHandler } from './charts/bscan-click-handler.js'; import { BScanClickHandler } from './charts/bscan-click-handler.js';
@ -374,12 +374,6 @@ export class ChartManager {
if (processorData) { if (processorData) {
downloadJSON(processorData, `${baseFilename}_data.json`); downloadJSON(processorData, `${baseFilename}_data.json`);
} }
this.notifications?.show?.({
type: 'success',
title: 'Скачивание завершено',
message: `Скачаны график и данные ${formatProcessorName(id)}`
});
} }
}; };
@ -396,12 +390,6 @@ export class ChartManager {
if (processorData) { if (processorData) {
downloadJSON(processorData, `${baseFilename}_data.json`); downloadJSON(processorData, `${baseFilename}_data.json`);
} }
this.notifications?.show?.({
type: 'warning',
title: 'Скачивание завершено',
message: `График скачан. Данные ограничены (нет подключения к серверу)`
});
} }
} catch (e) { } catch (e) {
console.error('Chart download failed:', e); console.error('Chart download failed:', e);
@ -592,12 +580,6 @@ export class ChartManager {
} }
console.log('Total exported files:', exportedCount); console.log('Total exported files:', exportedCount);
this.notifications?.show?.({
type: 'success',
title: 'Экспорт завершён',
message: `Данные свипов экспортированы для ${processorId}`
});
} }
exportPointsToTSV(points, vnaConfig, filename) { exportPointsToTSV(points, vnaConfig, filename) {
@ -723,6 +705,25 @@ export class ChartManager {
return; return;
} }
// Show dialog to choose whether to update settings or not
const choice = await showConfirmDialog({
title: 'Загрузка истории',
message: 'Обновить параметры процессора из файла или сохранить текущие настройки?',
buttons: [
{ value: 'cancel', text: 'Отмена', class: 'btn--secondary' },
{ value: 'data_only', text: 'Только данные', class: 'btn--primary' },
{ value: 'with_settings', text: 'Данные и настройки', class: 'btn--primary' }
]
});
// User cancelled
if (!choice || choice === 'cancel') {
return;
}
// Determine whether to include config
const configToSend = (choice === 'with_settings' && processorConfig) ? processorConfig : null;
// Send load_history message via WebSocket // Send load_history message via WebSocket
const websocket = window.vnaDashboard?.websocket; const websocket = window.vnaDashboard?.websocket;
if (websocket && websocket.ws && websocket.ws.readyState === WebSocket.OPEN) { if (websocket && websocket.ws && websocket.ws.readyState === WebSocket.OPEN) {
@ -730,13 +731,14 @@ export class ChartManager {
type: 'load_history', type: 'load_history',
processor_id: processorId, processor_id: processorId,
history_data: sweepHistory, history_data: sweepHistory,
config: processorConfig config: configToSend
})); }));
const settingsMsg = configToSend ? ' (с обновлением настроек)' : ' (без изменения настроек)';
this.notifications?.show?.({ this.notifications?.show?.({
type: 'success', type: 'success',
title: 'История загружена', title: 'История загружена',
message: `Загружено ${sweepHistory.length} записей для ${formatProcessorName(processorId)}` message: `Загружено ${sweepHistory.length} записей для ${formatProcessorName(processorId)}${settingsMsg}`
}); });
} else { } else {
this.notifications?.show?.({ this.notifications?.show?.({

View File

@ -89,6 +89,15 @@ export const API = {
REFERENCE_CURRENT: `${API_BASE}/settings/reference/current`, REFERENCE_CURRENT: `${API_BASE}/settings/reference/current`,
REFERENCE_ITEM: (name) => `${API_BASE}/settings/reference/${encodeURIComponent(name)}`, REFERENCE_ITEM: (name) => `${API_BASE}/settings/reference/${encodeURIComponent(name)}`,
REFERENCE_PLOT: (name) => `${API_BASE}/settings/reference/${encodeURIComponent(name)}/plot` REFERENCE_PLOT: (name) => `${API_BASE}/settings/reference/${encodeURIComponent(name)}/plot`
},
LASER: {
START: `${API_BASE}/laser/start`,
START_MANUAL: `${API_BASE}/laser/start-manual`,
STOP: `${API_BASE}/laser/stop`,
STATUS: `${API_BASE}/laser/status`,
CONNECT: `${API_BASE}/laser/connect`,
DISCONNECT: `${API_BASE}/laser/disconnect`
} }
}; };

View File

@ -6,6 +6,7 @@
import { PresetManager } from './settings/preset-manager.js'; import { PresetManager } from './settings/preset-manager.js';
import { CalibrationManager } from './settings/calibration-manager.js'; import { CalibrationManager } from './settings/calibration-manager.js';
import { ReferenceManager } from './settings/reference-manager.js'; import { ReferenceManager } from './settings/reference-manager.js';
import { LaserManager } from './settings/laser-manager.js';
import { Debouncer, ButtonState, downloadJSON } from './utils.js'; import { Debouncer, ButtonState, downloadJSON } from './utils.js';
import { renderIcons } from './icons.js'; import { renderIcons } from './icons.js';
import { import {
@ -34,6 +35,7 @@ export class SettingsManager {
this.presetManager = new PresetManager(notifications); this.presetManager = new PresetManager(notifications);
this.calibrationManager = new CalibrationManager(notifications); this.calibrationManager = new CalibrationManager(notifications);
this.referenceManager = new ReferenceManager(notifications); this.referenceManager = new ReferenceManager(notifications);
this.laserManager = new LaserManager(notifications);
// Plots modal state // Plots modal state
this.currentPlotsData = null; this.currentPlotsData = null;
@ -100,6 +102,23 @@ export class SettingsManager {
currentReferenceDescription: document.getElementById('currentReferenceDescription'), currentReferenceDescription: document.getElementById('currentReferenceDescription'),
currentReferenceCalibration: document.getElementById('currentReferenceCalibration'), currentReferenceCalibration: document.getElementById('currentReferenceCalibration'),
// Laser controls
laserManualMode: document.getElementById('laserManualMode'),
laserTemp1: document.getElementById('laserTemp1'),
laserTemp2: document.getElementById('laserTemp2'),
laserCurrent1: document.getElementById('laserCurrent1'),
laserCurrent2: document.getElementById('laserCurrent2'),
laserMinCurrent1: document.getElementById('laserMinCurrent1'),
laserMaxCurrent1: document.getElementById('laserMaxCurrent1'),
laserDeltaCurrent1: document.getElementById('laserDeltaCurrent1'),
laserScanTemp1: document.getElementById('laserScanTemp1'),
laserScanTemp2: document.getElementById('laserScanTemp2'),
laserScanCurrent2: document.getElementById('laserScanCurrent2'),
laserDeltaTime: document.getElementById('laserDeltaTime'),
laserTau: document.getElementById('laserTau'),
laserStartBtn: document.getElementById('laserStartBtn'),
laserStopBtn: document.getElementById('laserStopBtn'),
// Status // Status
presetCount: document.getElementById('presetCount'), presetCount: document.getElementById('presetCount'),
calibrationCount: document.getElementById('calibrationCount'), calibrationCount: document.getElementById('calibrationCount'),
@ -118,6 +137,23 @@ export class SettingsManager {
this.presetManager.init(this.elements); this.presetManager.init(this.elements);
this.calibrationManager.init(this.elements); this.calibrationManager.init(this.elements);
this.referenceManager.init(this.elements); this.referenceManager.init(this.elements);
this.laserManager.init({
manualMode: this.elements.laserManualMode,
temp1: this.elements.laserTemp1,
temp2: this.elements.laserTemp2,
current1: this.elements.laserCurrent1,
current2: this.elements.laserCurrent2,
minCurrent1: this.elements.laserMinCurrent1,
maxCurrent1: this.elements.laserMaxCurrent1,
deltaCurrent1: this.elements.laserDeltaCurrent1,
scanTemp1: this.elements.laserScanTemp1,
scanTemp2: this.elements.laserScanTemp2,
scanCurrent2: this.elements.laserScanCurrent2,
deltaTime: this.elements.laserDeltaTime,
tau: this.elements.laserTau,
startBtn: this.elements.laserStartBtn,
stopBtn: this.elements.laserStopBtn
});
// Setup callbacks // Setup callbacks
this.presetManager.onPresetChanged = async () => { this.presetManager.onPresetChanged = async () => {
@ -569,8 +605,6 @@ export class SettingsManager {
plot_data: plotsData.plot plot_data: plotsData.plot
}; };
downloadJSON(data, `${base}_data.json`); downloadJSON(data, `${base}_data.json`);
this.notify(SUCCESS, 'Скачивание завершено', `Скачаны график и данные эталона ${plotsData.reference_name}`);
} catch (e) { } catch (e) {
console.error('Download reference failed:', e); console.error('Download reference failed:', e);
this.notify(ERROR, 'Ошибка скачивания', 'Не удалось скачать данные эталона'); this.notify(ERROR, 'Ошибка скачивания', 'Не удалось скачать данные эталона');
@ -589,8 +623,6 @@ export class SettingsManager {
const data = this.prepareCalibrationDownloadData(standardName); const data = this.prepareCalibrationDownloadData(standardName);
downloadJSON(data, `${base}_data.json`); downloadJSON(data, `${base}_data.json`);
this.notify(SUCCESS, 'Скачивание завершено', `Скачаны график и данные стандарта ${standardName.toUpperCase()}`);
} catch (e) { } catch (e) {
console.error('Download standard failed:', e); console.error('Download standard failed:', e);
this.notify(ERROR, 'Ошибка скачивания', 'Не удалось скачать данные калибровки'); this.notify(ERROR, 'Ошибка скачивания', 'Не удалось скачать данные калибровки');

View File

@ -0,0 +1,406 @@
/**
* Laser Manager
* Handles laser control interface with two modes: manual and scan
*/
import { apiPost } from '../api-client.js';
import { API, NOTIFICATION_TYPES } from '../constants.js';
const { SUCCESS, ERROR } = NOTIFICATION_TYPES;
export class LaserManager {
constructor(notifications) {
this.notifications = notifications;
this.elements = {};
this.isRunning = false;
this.isManualMode = false;
// Bind methods
this.handleModeChange = this.handleModeChange.bind(this);
this.handleStartClick = this.handleStartClick.bind(this);
this.handleStopClick = this.handleStopClick.bind(this);
}
init(elements) {
this.elements = elements;
// Add event listeners
this.elements.manualMode?.addEventListener('change', this.handleModeChange);
this.elements.startBtn?.addEventListener('click', this.handleStartClick);
this.elements.stopBtn?.addEventListener('click', this.handleStopClick);
// Initialize UI state
this.updateUIState();
}
destroy() {
this.elements.manualMode?.removeEventListener('change', this.handleModeChange);
this.elements.startBtn?.removeEventListener('click', this.handleStartClick);
this.elements.stopBtn?.removeEventListener('click', this.handleStopClick);
}
handleModeChange() {
this.isManualMode = this.elements.manualMode?.checked || false;
this.updateUIState();
}
updateUIState() {
const manualSection = document.getElementById('laserManualSection');
const scanSection = document.getElementById('laserScanSection');
if (this.isManualMode) {
// Manual mode: show manual controls, hide scan controls
if (manualSection) manualSection.style.display = 'block';
if (scanSection) scanSection.style.display = 'none';
// Enable manual mode inputs only if not running
if (!this.isRunning) {
this.elements.temp1.disabled = false;
this.elements.temp2.disabled = false;
this.elements.current1.disabled = false;
this.elements.current2.disabled = false;
}
// Disable scan inputs
this.elements.minCurrent1.disabled = true;
this.elements.maxCurrent1.disabled = true;
this.elements.deltaCurrent1.disabled = true;
this.elements.scanTemp1.disabled = true;
this.elements.scanTemp2.disabled = true;
this.elements.scanCurrent2.disabled = true;
this.elements.deltaTime.disabled = true;
this.elements.tau.disabled = true;
} else {
// Scan mode: hide manual controls, show scan controls
if (manualSection) manualSection.style.display = 'none';
if (scanSection) scanSection.style.display = 'block';
// Disable manual mode inputs
this.elements.temp1.disabled = true;
this.elements.temp2.disabled = true;
this.elements.current1.disabled = true;
this.elements.current2.disabled = true;
// Enable scan inputs only if not running
if (!this.isRunning) {
this.elements.minCurrent1.disabled = false;
this.elements.maxCurrent1.disabled = false;
this.elements.deltaCurrent1.disabled = false;
this.elements.scanTemp1.disabled = false;
this.elements.scanTemp2.disabled = false;
this.elements.scanCurrent2.disabled = false;
this.elements.deltaTime.disabled = false;
this.elements.tau.disabled = false;
}
}
// Enable/disable start button based on running state
if (this.elements.startBtn) {
this.elements.startBtn.disabled = this.isRunning;
}
if (this.elements.stopBtn) {
this.elements.stopBtn.disabled = !this.isRunning;
}
}
async handleStartClick() {
if (this.isRunning) {
this.notify(ERROR, 'Ошибка', 'Цикл уже запущен');
return;
}
try {
// Disable start button during request
this.elements.startBtn.disabled = true;
let parameters;
let endpoint;
if (this.isManualMode) {
// Manual mode - use simplified endpoint with only t1, t2, i1, i2
parameters = this.collectManualParametersSimple();
endpoint = API.LASER.START_MANUAL;
} else {
// Scan mode - use full endpoint with scan parameters
parameters = this.collectScanParameters();
endpoint = API.LASER.START;
}
// Validate parameters
if (!this.validateParameters(parameters)) {
this.elements.startBtn.disabled = false;
return;
}
// Send start request to appropriate endpoint
const response = await apiPost(endpoint, parameters);
if (response.success) {
this.isRunning = true;
this.notify(SUCCESS, 'Успешно', response.message);
// Disable all controls during operation
this.disableAllControls();
// Update button states
this.elements.startBtn.disabled = true;
this.elements.stopBtn.disabled = false;
console.log('Laser cycle started:', parameters);
} else {
this.notify(ERROR, 'Ошибка', response.message);
this.elements.startBtn.disabled = false;
}
} catch (error) {
console.error('Failed to start laser cycle:', error);
this.notify(ERROR, 'Ошибка', `Не удалось запустить цикл: ${error.message}`);
this.elements.startBtn.disabled = false;
}
}
async handleStopClick() {
if (!this.isRunning) {
this.notify(ERROR, 'Ошибка', 'Цикл не запущен');
return;
}
try {
// Disable stop button during request
this.elements.stopBtn.disabled = true;
const response = await apiPost(API.LASER.STOP, {});
if (response.success) {
this.isRunning = false;
this.notify(SUCCESS, 'Успешно', response.message);
// Re-enable controls
this.enableAllControls();
this.updateUIState();
console.log('Laser cycle stopped');
} else {
this.notify(ERROR, 'Ошибка', response.message);
this.elements.stopBtn.disabled = false;
}
} catch (error) {
console.error('Failed to stop laser cycle:', error);
this.notify(ERROR, 'Ошибка', `Не удалось остановить цикл: ${error.message}`);
this.elements.stopBtn.disabled = false;
}
}
collectManualParameters() {
// Manual mode: use fixed temperature and current values
// Set min=max to indicate manual mode
const temp1 = parseFloat(this.elements.temp1.value);
const temp2 = parseFloat(this.elements.temp2.value);
const current1 = parseFloat(this.elements.current1.value);
const current2 = parseFloat(this.elements.current2.value);
return {
// Manual mode indicated by setting enable_c1 = false and all ranges equal
enable_c1: false,
enable_t1: false,
enable_t2: false,
enable_c2: false,
// Temperature values (same for min/max in manual mode)
min_temp_1: temp1,
max_temp_1: temp1,
delta_temp_1: 0.05,
min_temp_2: temp2,
max_temp_2: temp2,
delta_temp_2: 0.05,
// Current values (same for min/max in manual mode)
min_current_1: current1,
max_current_1: current1,
delta_current_1: 0.05,
min_current_2: current2,
max_current_2: current2,
delta_current_2: 0.05,
// Time parameters (not used in manual mode but required)
delta_time: 50,
tau: 10
};
}
collectManualParametersSimple() {
// Simplified manual mode: only 4 parameters (t1, t2, i1, i2)
return {
t1: parseFloat(this.elements.temp1.value),
t2: parseFloat(this.elements.temp2.value),
i1: parseFloat(this.elements.current1.value),
i2: parseFloat(this.elements.current2.value)
};
}
collectScanParameters() {
// Scan mode: scan current 1 while keeping other parameters fixed
return {
enable_c1: true, // Scanning current 1
enable_t1: false,
enable_t2: false,
enable_c2: false,
// Temperature 1 (fixed)
min_temp_1: parseFloat(this.elements.scanTemp1.value),
max_temp_1: parseFloat(this.elements.scanTemp1.value),
delta_temp_1: 0.05,
// Temperature 2 (fixed)
min_temp_2: parseFloat(this.elements.scanTemp2.value),
max_temp_2: parseFloat(this.elements.scanTemp2.value),
delta_temp_2: 0.05,
// Current 1 (scanning range)
min_current_1: parseFloat(this.elements.minCurrent1.value),
max_current_1: parseFloat(this.elements.maxCurrent1.value),
delta_current_1: parseFloat(this.elements.deltaCurrent1.value),
// Current 2 (fixed)
min_current_2: parseFloat(this.elements.scanCurrent2.value),
max_current_2: parseFloat(this.elements.scanCurrent2.value),
delta_current_2: 0.05,
// Time parameters
delta_time: parseInt(this.elements.deltaTime.value),
tau: parseInt(this.elements.tau.value)
};
}
validateParameters(params) {
// Check if simplified format (t1, t2, i1, i2)
if ('t1' in params && 't2' in params && 'i1' in params && 'i2' in params) {
// Simplified format validation
const values = [params.t1, params.t2, params.i1, params.i2];
if (values.some(v => isNaN(v))) {
this.notify(ERROR, 'Ошибка валидации', 'Все поля должны быть заполнены корректными числами');
return false;
}
// Check temperature ranges
if (params.t1 < -1 || params.t1 > 45 || params.t2 < -1 || params.t2 > 45) {
this.notify(ERROR, 'Ошибка валидации', 'Температура должна быть от -1 до 45°C');
return false;
}
// Check current ranges
if (params.i1 < 15 || params.i1 > 70 || params.i2 < 15 || params.i2 > 60) {
this.notify(ERROR, 'Ошибка валидации', 'Ток лазера 1: 15-70мА, лазера 2: 15-60мА');
return false;
}
return true;
}
// Full format validation (original)
// Check for NaN values
const values = [
params.min_temp_1, params.max_temp_1,
params.min_temp_2, params.max_temp_2,
params.min_current_1, params.max_current_1,
params.min_current_2, params.max_current_2,
params.delta_time, params.tau
];
if (values.some(v => isNaN(v))) {
this.notify(ERROR, 'Ошибка валидации', 'Все поля должны быть заполнены корректными числами');
return false;
}
// Check temperature ranges
if (params.min_temp_1 < -1 || params.min_temp_1 > 45 ||
params.max_temp_1 < -1 || params.max_temp_1 > 45 ||
params.min_temp_2 < -1 || params.min_temp_2 > 45 ||
params.max_temp_2 < -1 || params.max_temp_2 > 45) {
this.notify(ERROR, 'Ошибка валидации', 'Температура должна быть от -1 до 45°C');
return false;
}
// Check current ranges
if (params.min_current_1 < 15 || params.max_current_1 > 70 ||
params.min_current_2 < 15 || params.max_current_2 > 60) {
this.notify(ERROR, 'Ошибка валидации', 'Ток должен быть в допустимых пределах');
return false;
}
// In scan mode, check min < max for current 1
if (!this.isManualMode) {
if (params.min_current_1 >= params.max_current_1) {
this.notify(ERROR, 'Ошибка валидации',
'Минимальный ток лазера 1 должен быть меньше максимального');
return false;
}
// Check delta_time is multiple of 10
if (params.delta_time % 10 !== 0) {
this.notify(ERROR, 'Ошибка валидации',
'Шаг дискретизации времени должен быть кратен 10 мкс');
return false;
}
// Check time parameters
if (params.delta_time < 20 || params.delta_time > 100) {
this.notify(ERROR, 'Ошибка валидации',
'Шаг дискретизации времени должен быть от 20 до 100 мкс');
return false;
}
if (params.tau < 3 || params.tau > 10) {
this.notify(ERROR, 'Ошибка валидации',
'Время задержки должно быть от 3 до 10 мс');
return false;
}
}
return true;
}
disableAllControls() {
// Disable mode checkbox
if (this.elements.manualMode) {
this.elements.manualMode.disabled = true;
}
// Disable all input fields
const allInputs = [
this.elements.temp1,
this.elements.temp2,
this.elements.current1,
this.elements.current2,
this.elements.minCurrent1,
this.elements.maxCurrent1,
this.elements.deltaCurrent1,
this.elements.scanTemp1,
this.elements.scanTemp2,
this.elements.scanCurrent2,
this.elements.deltaTime,
this.elements.tau
];
allInputs.forEach(input => {
if (input) input.disabled = true;
});
}
enableAllControls() {
// Enable mode checkbox
if (this.elements.manualMode) {
this.elements.manualMode.disabled = false;
}
// UI state will be updated by updateUIState() call after this
}
notify(type, title, message) {
if (this.notifications) {
this.notifications.show(type, title, message);
}
}
}

View File

@ -311,3 +311,77 @@ export function formatBytes(bytes) {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
} }
/**
* Show a confirmation dialog with custom options
* @param {Object} options - Dialog options
* @param {string} options.title - Dialog title
* @param {string} options.message - Dialog message
* @param {Array<Object>} options.buttons - Array of button configurations
* @returns {Promise<string>} Resolves with the button value that was clicked
*/
export function showConfirmDialog({ title, message, buttons }) {
return new Promise((resolve) => {
// Create modal structure
const modal = document.createElement('div');
modal.className = 'modal modal--active';
const buttonsHtml = buttons.map(btn =>
`<button class="btn ${btn.class || 'btn--secondary'}" data-value="${btn.value}">${btn.text}</button>`
).join('');
modal.innerHTML = `
<div class="modal__backdrop" style="background-color: rgba(0, 0, 0, 0.85);"></div>
<div class="modal__content" style="background-color: #1e293b;">
<div class="modal__header">
<h3 class="modal__title">${escapeHtml(title)}</h3>
</div>
<div class="modal__body" style="padding: var(--space-6);">
<p style="margin: 0; color: var(--color-text-secondary);">${escapeHtml(message)}</p>
</div>
<div style="padding: var(--space-6); padding-top: 0; display: flex; gap: var(--space-3); justify-content: flex-end;">
${buttonsHtml}
</div>
</div>
`;
document.body.appendChild(modal);
// Handle button clicks
const handleClick = (e) => {
const button = e.target.closest('[data-value]');
if (button) {
const value = button.dataset.value;
cleanup();
resolve(value);
}
};
// Handle backdrop click
const handleBackdrop = (e) => {
if (e.target.classList.contains('modal__backdrop')) {
cleanup();
resolve(null);
}
};
// Handle escape key
const handleEscape = (e) => {
if (e.key === 'Escape') {
cleanup();
resolve(null);
}
};
const cleanup = () => {
modal.removeEventListener('click', handleClick);
modal.removeEventListener('click', handleBackdrop);
document.removeEventListener('keydown', handleEscape);
modal.remove();
};
modal.addEventListener('click', handleClick);
modal.addEventListener('click', handleBackdrop);
document.addEventListener('keydown', handleEscape);
});
}

View File

@ -327,6 +327,121 @@
</div> </div>
</div> </div>
<!-- Laser Control Section -->
<div class="settings-card">
<h3 class="settings-card-title">Управление лазером</h3>
<p class="settings-card-description">Контроль параметров лазерной схемы</p>
<div class="laser-controls">
<!-- Mode Selection -->
<div class="control-section">
<h4 class="control-section-title">Режим работы</h4>
<div class="control-group">
<label class="control-label control-label--checkbox">
<input type="checkbox" id="laserManualMode">
<span>Ручной режим ввода</span>
</label>
</div>
</div>
<!-- Manual Mode Controls -->
<div class="control-section" id="laserManualSection">
<h4 class="control-section-title">Параметры ручного режима</h4>
<div class="laser-params-table">
<div class="laser-param-cell laser-param-header">T1 (°C)</div>
<div class="laser-param-cell laser-param-header">T2 (°C)</div>
<div class="laser-param-cell laser-param-header">Ток L1 (мА)</div>
<div class="laser-param-cell laser-param-header">Ток L2 (мА)</div>
<div class="laser-param-cell laser-param-input">
<input type="number" class="settings-input" id="laserTemp1"
min="-1" max="45" step="0.1" value="28">
</div>
<div class="laser-param-cell laser-param-input">
<input type="number" class="settings-input" id="laserTemp2"
min="-1" max="45" step="0.1" value="28.9">
</div>
<div class="laser-param-cell laser-param-input">
<input type="number" class="settings-input" id="laserCurrent1"
min="15" max="60" step="0.1" value="33">
</div>
<div class="laser-param-cell laser-param-input">
<input type="number" class="settings-input" id="laserCurrent2"
min="15" max="60" step="0.1" value="35">
</div>
</div>
</div>
<!-- Scan Mode Controls -->
<div class="control-section" id="laserScanSection" style="display: none;">
<h4 class="control-section-title">Параметры сканирования тока лазера 1</h4>
<div class="laser-params-table">
<!-- Row 1: Headers -->
<div class="laser-param-cell laser-param-header">Мин ток L1 (мА)</div>
<div class="laser-param-cell laser-param-header">Макс ток L1 (мА)</div>
<div class="laser-param-cell laser-param-header">Шаг тока L1 (мА)</div>
<div class="laser-param-cell laser-param-header">Δt (мкс)</div>
<!-- Row 2: Inputs -->
<div class="laser-param-cell laser-param-input">
<input type="number" class="settings-input" id="laserMinCurrent1"
min="15" max="70" step="0.1" value="33" disabled>
</div>
<div class="laser-param-cell laser-param-input">
<input type="number" class="settings-input" id="laserMaxCurrent1"
min="15" max="70" step="0.1" value="70" disabled>
</div>
<div class="laser-param-cell laser-param-input">
<input type="number" class="settings-input" id="laserDeltaCurrent1"
min="0.002" max="0.5" step="0.001" value="0.05" disabled>
</div>
<div class="laser-param-cell laser-param-input">
<input type="number" class="settings-input" id="laserDeltaTime"
min="20" max="100" step="10" value="50" disabled>
</div>
<!-- Row 3: Fixed params headers -->
<div class="laser-param-cell laser-param-header">T1 (°C)</div>
<div class="laser-param-cell laser-param-header">T2 (°C)</div>
<div class="laser-param-cell laser-param-header">Ток L2 (мА)</div>
<div class="laser-param-cell laser-param-header">Tau (мс)</div>
<!-- Row 4: Fixed params inputs -->
<div class="laser-param-cell laser-param-input">
<input type="number" class="settings-input" id="laserScanTemp1"
min="-1" max="45" step="0.1" value="28" disabled>
</div>
<div class="laser-param-cell laser-param-input">
<input type="number" class="settings-input" id="laserScanTemp2"
min="-1" max="45" step="0.1" value="28.9" disabled>
</div>
<div class="laser-param-cell laser-param-input">
<input type="number" class="settings-input" id="laserScanCurrent2"
min="15" max="60" step="0.1" value="35" disabled>
</div>
<div class="laser-param-cell laser-param-input">
<input type="number" class="settings-input" id="laserTau"
min="3" max="10" step="1" value="10" disabled>
</div>
</div>
</div>
<!-- Control Buttons -->
<div class="control-section">
<div class="control-group control-group--buttons">
<button class="btn btn--primary" id="laserStartBtn">
<span data-icon="play"></span>
Пуск
</button>
<button class="btn btn--secondary" id="laserStopBtn" disabled>
<span data-icon="square"></span>
Стоп
</button>
</div>
</div>
</div>
</div>
<!-- System Summary --> <!-- System Summary -->
<div class="settings-card"> <div class="settings-card">
<h3 class="settings-card-title">Сводка системы</h3> <h3 class="settings-card-title">Сводка системы</h3>