Compare commits

..

10 Commits

Author SHA1 Message Date
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
20 changed files with 1835 additions and 134 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

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

View File

@ -0,0 +1,509 @@
import logging
import time
from typing import Optional, Dict, Any
from datetime import datetime
from ...api.models.laser import LaserParameters, LaserStatus
from .RadioPhotonic_PCB_PC_software import device_interaction as dev
from .RadioPhotonic_PCB_PC_software import device_commands as cmd
logger = logging.getLogger(__name__)
class LaserController:
"""
Controller for laser control system.
Communicates with RadioPhotonic board via serial port (115200 baud).
Supports both manual control and automated scanning modes.
"""
def __init__(self):
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
# Default PI coefficients (multiplied by 256 as per device protocol)
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"
logger.info("LaserController initialized")
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.
"""
try:
# Prepare control parameters
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: {params['Temp_1']}°C, T2: {params['Temp_2']}°C")
logger.info(f" I1: {params['Iset_1']} mA, I2: {params['Iset_2']} mA")
# Send control parameters to device
dev.send_control_parameters(self.prt, params)
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.
Uses TASK_ENABLE (0x7777) command with TaskType.
"""
try:
# First, send initial control parameters to set the device to starting values
logger.info("Setting initial control parameters before scan...")
initial_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"Initial parameters: T1={initial_params['Temp_1']}°C, T2={initial_params['Temp_2']}°C, "
f"I1={initial_params['Iset_1']} mA, I2={initial_params['Iset_2']} mA")
# Send initial control parameters
dev.send_control_parameters(self.prt, initial_params)
logger.info("Initial control parameters sent successfully")
# Small delay to allow device to process
import time
time.sleep(0.2)
# Determine which parameter to scan
if parameters.enable_c1:
task_type = cmd.TaskType.ChangeCurrentLD1.value
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 = cmd.TaskType.ChangeCurrentLD2.value
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
}
# Build task parameters based on task type
sending_param = {
'TaskType': task_type,
'Dt': parameters.delta_time / 1000.0, # Convert μs to ms
'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 == cmd.TaskType.ChangeCurrentLD1.value:
sending_param.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 == cmd.TaskType.ChangeCurrentLD2.value:
sending_param.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
dev.send_task_command(self.prt, sending_param)
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.
Sends reset command to device.
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:
# Send reset command to device
try:
dev.reset_port_settings(self.prt)
logger.info("Device reset command sent")
except Exception as e:
logger.warning(f"Failed to send reset command: {e}")
self.is_running = False
self.current_status.is_running = False
self.current_parameters = None
return {
"success": True,
"message": "Цикл управления лазером остановлен"
}
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
if self.prt is not None:
try:
cmd.close_port(self.prt)
logger.info("Serial port closed")
except Exception as e:
logger.warning(f"Error closing serial port: {e}")
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

@ -3,6 +3,6 @@
"y_max": 40, "y_max": 40,
"autoscale": true, "autoscale": true,
"show_magnitude": true, "show_magnitude": true,
"show_phase": true, "show_phase": false,
"open_air": false "open_air": false
} }

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

@ -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
@ -23,3 +24,6 @@ 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,130 @@
</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">Температура лазера 1 (°C)</div>
<div class="laser-param-cell laser-param-header">Температура лазера 2 (°C)</div>
<div class="laser-param-cell laser-param-header">Управляющий ток лазера 1 (15-60 мА)</div>
<div class="laser-param-cell laser-param-header">Управляющий ток лазера 2 (15-60 мА)</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="control-subsection">
<h5 class="control-subsection-title">Параметры сканирования</h5>
<div class="control-grid">
<div class="control-group">
<label class="control-label">Минимальный ток лазера 1 (мА):</label>
<input type="number" class="settings-input" id="laserMinCurrent1"
min="15" max="70" step="0.1" value="33" disabled>
</div>
<div class="control-group">
<label class="control-label">Максимальный ток лазера 1 (мА):</label>
<input type="number" class="settings-input" id="laserMaxCurrent1"
min="15" max="70" step="0.1" value="70" disabled>
</div>
<div class="control-group">
<label class="control-label">Шаг дискретизации тока лазера 1 (0.002-0.5 мА):</label>
<input type="number" class="settings-input" id="laserDeltaCurrent1"
min="0.002" max="0.5" step="0.001" value="0.05" disabled>
</div>
</div>
</div>
<div class="control-subsection">
<h5 class="control-subsection-title">Фиксированные параметры</h5>
<div class="control-grid">
<div class="control-group">
<label class="control-label">Температура лазера 1 (°C):</label>
<input type="number" class="settings-input" id="laserScanTemp1"
min="-1" max="45" step="0.1" value="28" disabled>
</div>
<div class="control-group">
<label class="control-label">Температура лазера 2 (°C):</label>
<input type="number" class="settings-input" id="laserScanTemp2"
min="-1" max="45" step="0.1" value="28.9" disabled>
</div>
<div class="control-group">
<label class="control-label">Управляющий ток лазера 2 (мА):</label>
<input type="number" class="settings-input" id="laserScanCurrent2"
min="15" max="60" step="0.1" value="35" disabled>
</div>
</div>
</div>
<div class="control-subsection">
<h5 class="control-subsection-title">Параметры времени</h5>
<div class="control-grid">
<div class="control-group">
<label class="control-label">Шаг дискретизации времени (20-100 мкс, шаг 10):</label>
<input type="number" class="settings-input" id="laserDeltaTime"
min="20" max="100" step="10" value="50" disabled>
</div>
<div class="control-group">
<label class="control-label">Время задержки (3-10 мс):</label>
<input type="number" class="settings-input" id="laserTau"
min="3" max="10" step="1" value="10" disabled>
</div>
</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>