From a1386e2588a7ced79ebee1f6faf153a7fb100206 Mon Sep 17 00:00:00 2001 From: Ayzen Date: Fri, 9 Oct 2026 19:45:30 +0300 Subject: [PATCH] sweep feature added --- README.md | 1 + laser_control/constants.py | 20 +++++- laser_control/controller.py | 29 +++++++- laser_control/gui/sections.py | 121 +++++++++++++++++++++++++++++++++- laser_control/gui/window.py | 9 ++- laser_control/gui/worker.py | 24 +++++++ laser_control/models.py | 22 ++++++- laser_control/protocol.py | 21 +++++- 8 files changed, 241 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f172a8c..8a43ac4 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ PyQt6-приложение для управления лазерной плат - ручной режим: `T1/T2/I1/I2` - live telemetry: `T1/T2`, внешние термисторы, фотодиоды, `3V3/5V1/5V2/7V0` - TEC drive modulation: синусоидальная добавка к выходу TEC PID для выбранного лазера +- свипирование тока лазера 2 и STM32 DAC: лесенка min..max с заданным шагом, временем шага и паузой между свипами - AD9102: saw/SRAM режимы и загрузка custom waveform - AD9833, DS1809 и STM32 DAC через отдельные firmware-команды - сохранение профиля на SD-карту устройства diff --git a/laser_control/constants.py b/laser_control/constants.py index c2f057c..ff85b6d 100644 --- a/laser_control/constants.py +++ b/laser_control/constants.py @@ -17,6 +17,7 @@ SHORT_CONTROL_TOTAL_LENGTH = 10 WAVE_DATA_TOTAL_LENGTH = 30 PROFILE_SAVE_CONTROL_TOTAL_LENGTH = 30 PROFILE_SAVE_DATA_TOTAL_LENGTH = 30 +SWEEP_CONTROL_TOTAL_LENGTH = 16 SHORT_COMMAND_LENGTH = 2 STATUS_RESPONSE_LENGTH = 2 @@ -35,6 +36,7 @@ CMD_AD9102_WAVE_CONTROL = 0xCCCC CMD_AD9102_WAVE_DATA = 0xDDDD CMD_PROFILE_SAVE_DATA = 0xEEEE CMD_TEC_MODULATION_CONTROL = 0xF0F0 +CMD_SWEEP_CONTROL = 0xF1F1 # ---- Setup-word bit layout from firmware app_decode_work_packet() @@ -106,6 +108,9 @@ STM32_DAC_FLAG_ENABLE = 0x0001 TEC_MODULATION_FLAG_ENABLE = 0x0001 TEC_MODULATION_FLAG_CHANNEL_2 = 0x0002 +SWEEP_FLAG_ENABLE = 0x0001 +SWEEP_FLAG_TARGET_STM32_DAC = 0x0002 + AD9102_WAVE_OPCODE_BEGIN = 0x0001 AD9102_WAVE_OPCODE_COMMIT = 0x0002 AD9102_WAVE_OPCODE_CANCEL = 0x0003 @@ -194,7 +199,14 @@ STM32_DAC_CODE_MAX = 4095 TEC_MODULATION_FREQUENCY_MIN_HZ = 50 TEC_MODULATION_FREQUENCY_MAX_HZ = 2_000 TEC_MODULATION_AMPLITUDE_CODE_MIN = 0 -TEC_MODULATION_AMPLITUDE_CODE_MAX = 4_096 +TEC_MODULATION_AMPLITUDE_CODE_MAX = 27_900 # Half of the firmware TEC drive range 1000..56800. + +# Firmware sweep tick: step periods are rounded down to a multiple of it. +SWEEP_TICK_US = 10 +SWEEP_STEP_PERIOD_MIN_US = 20 +SWEEP_STEP_PERIOD_MAX_US = 65_530 +SWEEP_PAUSE_MAX_MS = 65_535 +SWEEP_CURRENT_STEP_MIN_MA = 0.002 # One laser DAC code is ~0.001 mA. # ---- Rail tolerances @@ -232,6 +244,12 @@ DEFAULT_STM32_DAC_CODE = round( ) DEFAULT_TEC_MODULATION_FREQUENCY_HZ = 1_000 DEFAULT_TEC_MODULATION_AMPLITUDE_CODE = 256 +DEFAULT_SWEEP_CURRENT_MIN_MA = 30.0 +DEFAULT_SWEEP_CURRENT_MAX_MA = 40.0 +DEFAULT_SWEEP_CURRENT_STEP_MA = 0.05 +DEFAULT_SWEEP_DAC_STEP_CODE = 16 +DEFAULT_SWEEP_STEP_PERIOD_US = 50 +DEFAULT_SWEEP_PAUSE_MS = 5 DEFAULT_PI_P = 2560 DEFAULT_PI_I = 128 diff --git a/laser_control/controller.py b/laser_control/controller.py index 8d93214..babcd79 100644 --- a/laser_control/controller.py +++ b/laser_control/controller.py @@ -49,6 +49,9 @@ from .constants import ( PROFILE_SAVE_SECTION_WAVEFORM_TEXT, STM32_DAC_CODE_MAX, STM32_DAC_CODE_MIN, + SWEEP_PAUSE_MAX_MS, + SWEEP_STEP_PERIOD_MAX_US, + SWEEP_STEP_PERIOD_MIN_US, STATUS_RESPONSE_LENGTH, WAIT_AFTER_SEND_SEC, TEC_MODULATION_AMPLITUDE_CODE_MAX, @@ -62,7 +65,14 @@ from .exceptions import ( DeviceStateError, InvalidParameterError, ) -from .models import DeviceState, DeviceStatus, Measurements, ProfileSaveRequest +from .models import ( + DeviceState, + DeviceStatus, + Measurements, + ProfileSaveRequest, + SweepSettings, + SweepTarget, +) from .protocol import Protocol from .transport import SerialTransport from .validators import ParameterValidator @@ -472,6 +482,23 @@ class LaserController: amplitude_code, ) + def configure_sweep(self, settings: SweepSettings) -> None: + """Start (or stop, if disabled) a firmware staircase sweep of laser 2 current or the STM32 DAC.""" + if settings.enabled: + code_max = STM32_DAC_CODE_MAX if settings.target == SweepTarget.STM32_DAC else 0xFFFF + self._validate_int_range(settings.min_code, "min_code", 0, code_max) + self._validate_int_range(settings.max_code, "max_code", settings.min_code, code_max) + self._validate_int_range(settings.step_code, "step_code", 1, code_max) + self._validate_int_range( + settings.step_period_us, + "step_period_us", + SWEEP_STEP_PERIOD_MIN_US, + SWEEP_STEP_PERIOD_MAX_US, + ) + self._validate_int_range(settings.pause_ms, "pause_ms", 0, SWEEP_PAUSE_MAX_MS) + self._send_and_expect_ok(Protocol.encode_sweep_control(settings)) + logger.info("Sweep configured: %s", settings) + def save_profile_to_sd(self, request: ProfileSaveRequest) -> None: """Stream a rendered profile INI and optional waveform CSV to the device SD card.""" if not isinstance(request, ProfileSaveRequest): diff --git a/laser_control/gui/sections.py b/laser_control/gui/sections.py index 749f147..9bd1ca7 100644 --- a/laser_control/gui/sections.py +++ b/laser_control/gui/sections.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Callable + from PyQt6.QtWidgets import ( QCheckBox, QComboBox, @@ -52,6 +54,12 @@ from laser_control.constants import ( DEFAULT_DS1809_PROFILE_POSITION, DEFAULT_DS1809_PULSE_MS, DEFAULT_STM32_DAC_CODE, + DEFAULT_SWEEP_CURRENT_MAX_MA, + DEFAULT_SWEEP_CURRENT_MIN_MA, + DEFAULT_SWEEP_CURRENT_STEP_MA, + DEFAULT_SWEEP_DAC_STEP_CODE, + DEFAULT_SWEEP_PAUSE_MS, + DEFAULT_SWEEP_STEP_PERIOD_US, DEFAULT_TEC_MODULATION_AMPLITUDE_CODE, DEFAULT_TEC_MODULATION_FREQUENCY_HZ, DEFAULT_TEMP1_C, @@ -67,6 +75,11 @@ from laser_control.constants import ( AD9833_OUTPUT_FREQ_MIN_HZ, STM32_DAC_CODE_MAX, STM32_DAC_CODE_MIN, + SWEEP_CURRENT_STEP_MIN_MA, + SWEEP_PAUSE_MAX_MS, + SWEEP_STEP_PERIOD_MAX_US, + SWEEP_STEP_PERIOD_MIN_US, + SWEEP_TICK_US, TEC_MODULATION_AMPLITUDE_CODE_MAX, TEC_MODULATION_AMPLITUDE_CODE_MIN, TEC_MODULATION_FREQUENCY_MAX_HZ, @@ -74,6 +87,8 @@ from laser_control.constants import ( TEMP_MAX_C, TEMP_MIN_C, ) +from laser_control.conversions import current_ma_to_n +from laser_control.models import SweepSettings, SweepTarget def _double_spinbox( @@ -112,6 +127,64 @@ def _expanding_button(label: str, *, primary: bool = False) -> QPushButton: return button +class SweepControls(QGroupBox): + """Checkbox-enabled firmware staircase sweep settings for one output.""" + + def __init__( + self, + title: str, + target: SweepTarget, + *, + minimum: QSpinBox | QDoubleSpinBox, + maximum: QSpinBox | QDoubleSpinBox, + step: QSpinBox | QDoubleSpinBox, + to_code: Callable[[float], int], + on_apply: Callable[[SweepSettings], None], + ) -> None: + super().__init__(title) + self._target = target + self._to_code = to_code + self._enable = QCheckBox("Включить свипирование") + self._minimum = minimum + self._maximum = maximum + self._step = step + self._step_period_us = _int_spinbox( + SWEEP_STEP_PERIOD_MIN_US, + SWEEP_STEP_PERIOD_MAX_US, + DEFAULT_SWEEP_STEP_PERIOD_US, + suffix=" мкс", + ) + self._step_period_us.setSingleStep(SWEEP_TICK_US) + self._step_period_us.setToolTip(f"Разрешение {SWEEP_TICK_US} мкс, значение округляется вниз.") + self._pause_ms = _int_spinbox(0, SWEEP_PAUSE_MAX_MS, DEFAULT_SWEEP_PAUSE_MS, suffix=" мс") + self._pause_ms.setToolTip("Выход удерживается на нижней границе перед каждым новым свипом.") + self.apply_button = _expanding_button("Применить", primary=True) + self.apply_button.clicked.connect(lambda: on_apply(self.settings())) + + layout = QFormLayout(self) + layout.setHorizontalSpacing(12) + layout.setVerticalSpacing(8) + layout.addRow(self._enable) + layout.addRow("Нижняя граница", minimum) + layout.addRow("Верхняя граница", maximum) + layout.addRow("Шаг", step) + layout.addRow("Время шага", self._step_period_us) + layout.addRow("Пауза между свипами", self._pause_ms) + layout.addRow(self.apply_button) + + def settings(self) -> SweepSettings: + """Return the current inputs converted to firmware codes.""" + return SweepSettings( + target=self._target, + enabled=self._enable.isChecked(), + min_code=self._to_code(self._minimum.value()), + max_code=self._to_code(self._maximum.value()), + step_code=self._to_code(self._step.value()), + step_period_us=self._step_period_us.value(), + pause_ms=self._pause_ms.value(), + ) + + def build_manual_group(owner) -> QGroupBox: """Create manual control inputs.""" group = QGroupBox("Ручной режим") @@ -159,6 +232,7 @@ def build_device_group(owner) -> QGroupBox: tabs.addTab(_build_ad9833_tab(owner), "Генератор AD9833") tabs.addTab(_build_aux_tab(owner), "Выходы и DS1809") tabs.addTab(_build_tec_modulation_tab(owner), "TEC модуляция") + tabs.addTab(_build_sweep_tab(owner), "Свипирование") tabs.addTab(_build_wave_tab(owner), "Своя форма") layout.addWidget(tabs) return group @@ -479,7 +553,7 @@ def _build_tec_modulation_tab(owner) -> QWidget: TEC_MODULATION_AMPLITUDE_CODE_MAX, DEFAULT_TEC_MODULATION_AMPLITUDE_CODE, ) - owner._tec_mod_amplitude_code.setSingleStep(16) + owner._tec_mod_amplitude_code.setSingleStep(256) owner._tec_mod_amplitude_code.setGroupSeparatorShown(True) owner._apply_tec_modulation_button = _expanding_button("Применить TEC модуляцию", primary=True) owner._apply_tec_modulation_button.clicked.connect(owner._on_apply_tec_modulation) @@ -504,6 +578,51 @@ def _build_tec_modulation_tab(owner) -> QWidget: return tab +def _build_sweep_tab(owner) -> QWidget: + tab = QWidget() + layout = QHBoxLayout(tab) + layout.setSpacing(10) + + owner._laser2_sweep = SweepControls( + "Ток лазера 2", + SweepTarget.LASER2_CURRENT, + minimum=_double_spinbox( + CURRENT_MIN_MA, CURRENT_MAX_MA, DEFAULT_SWEEP_CURRENT_MIN_MA, decimals=3, step=0.05, suffix=" мА" + ), + maximum=_double_spinbox( + CURRENT_MIN_MA, CURRENT_MAX_MA, DEFAULT_SWEEP_CURRENT_MAX_MA, decimals=3, step=0.05, suffix=" мА" + ), + step=_double_spinbox( + SWEEP_CURRENT_STEP_MIN_MA, + CURRENT_MAX_MA - CURRENT_MIN_MA, + DEFAULT_SWEEP_CURRENT_STEP_MA, + decimals=3, + step=0.01, + suffix=" мА", + ), + to_code=current_ma_to_n, + on_apply=owner._on_apply_sweep, + ) + owner._laser2_sweep.setToolTip( + "Работает только в рабочем режиме (после «Применить» в ручном режиме); " + "после остановки возвращается ручной ток лазера 2." + ) + owner._stm32_dac_sweep = SweepControls( + "STM32 DAC (PA4)", + SweepTarget.STM32_DAC, + minimum=_int_spinbox(STM32_DAC_CODE_MIN, STM32_DAC_CODE_MAX, STM32_DAC_CODE_MIN), + maximum=_int_spinbox(STM32_DAC_CODE_MIN, STM32_DAC_CODE_MAX, STM32_DAC_CODE_MAX), + step=_int_spinbox(1, STM32_DAC_CODE_MAX, DEFAULT_SWEEP_DAC_STEP_CODE), + to_code=int, + on_apply=owner._on_apply_sweep, + ) + owner._stm32_dac_sweep.setToolTip("После остановки возвращается ручной уровень выхода STM32 DAC.") + + layout.addWidget(owner._laser2_sweep) + layout.addWidget(owner._stm32_dac_sweep) + return tab + + def _build_wave_tab(owner) -> QWidget: tab = QWidget() layout = QVBoxLayout(tab) diff --git a/laser_control/gui/window.py b/laser_control/gui/window.py index 3126e12..2cdfbda 100644 --- a/laser_control/gui/window.py +++ b/laser_control/gui/window.py @@ -45,7 +45,7 @@ from laser_control.controller import ( ad9102_sram_frequency_limits_hz, ad9102_sram_sample_count_from_frequency_hz, ) -from laser_control.models import DeviceStatus, Measurements, ProfileSaveRequest +from laser_control.models import DeviceStatus, Measurements, ProfileSaveRequest, SweepSettings from .dialogs import ProfileSaveDialog from .sections import ( @@ -68,6 +68,7 @@ class MainWindow(QMainWindow): request_pulse_ds1809 = pyqtSignal(bool, int, int) request_set_stm32_dac = pyqtSignal(bool, int) request_apply_tec_modulation = pyqtSignal(bool, int, int, int) + request_apply_sweep = pyqtSignal(object) request_upload_wave = pyqtSignal(object, int) request_cancel_wave = pyqtSignal() request_save_profile = pyqtSignal(object) @@ -222,6 +223,7 @@ class MainWindow(QMainWindow): self.request_pulse_ds1809.connect(self._worker.pulse_ds1809) self.request_set_stm32_dac.connect(self._worker.set_stm32_dac) self.request_apply_tec_modulation.connect(self._worker.apply_tec_modulation) + self.request_apply_sweep.connect(self._worker.apply_sweep) self.request_upload_wave.connect(self._worker.upload_ad9102_waveform) self.request_cancel_wave.connect(self._worker.cancel_ad9102_waveform_upload) self.request_save_profile.connect(self._worker.save_profile) @@ -333,6 +335,9 @@ class MainWindow(QMainWindow): ) ) + def _on_apply_sweep(self, settings: SweepSettings) -> None: + self._dispatch_command(lambda: self.request_apply_sweep.emit(settings)) + def _on_save_profile(self) -> None: dialog = ProfileSaveDialog( custom_waveform_available=self._custom_waveform_is_available(), @@ -443,6 +448,8 @@ class MainWindow(QMainWindow): self._apply_ad9833_button.setEnabled(connected) self._apply_stm32_dac_button.setEnabled(connected) self._apply_tec_modulation_button.setEnabled(connected) + self._laser2_sweep.apply_button.setEnabled(connected) + self._stm32_dac_sweep.apply_button.setEnabled(connected) self._pulse_ds1809_button.setEnabled(connected) self._upload_wave_button.setEnabled(connected) self._cancel_wave_button.setEnabled(connected) diff --git a/laser_control/gui/worker.py b/laser_control/gui/worker.py index 8e4126b..1ae50db 100644 --- a/laser_control/gui/worker.py +++ b/laser_control/gui/worker.py @@ -12,6 +12,7 @@ from laser_control import ( DeviceNotRespondingError, LaserController, ) +from laser_control.models import SweepSettings, SweepTarget class ControllerWorker(QObject): @@ -117,6 +118,16 @@ class ControllerWorker(QObject): ) ) + @pyqtSlot(object) + def apply_sweep(self, settings: object) -> None: + """Start or stop a firmware staircase sweep.""" + self._run_command( + lambda: ( + self._ensure_connected(), + self._apply_sweep_impl(settings), + ) + ) + @pyqtSlot(object) def save_profile(self, request: object) -> None: """Save the current GUI configuration to the device SD card.""" @@ -292,6 +303,19 @@ class ControllerWorker(QObject): ) self._emit_status() + def _apply_sweep_impl(self, settings: SweepSettings) -> None: + self._controller.configure_sweep(settings) + output = "STM32 DAC" if settings.target == SweepTarget.STM32_DAC else "Laser 2 current" + if settings.enabled: + self.log_message.emit( + "INFO", + f"{output} sweep started: codes {settings.min_code}..{settings.max_code} " + f"step {settings.step_code}, {settings.step_period_us} us/step, pause {settings.pause_ms} ms", + ) + else: + self.log_message.emit("INFO", f"{output} sweep stopped") + self._emit_status() + def _save_profile_impl(self, request: object) -> None: self._controller.save_profile_to_sd(request) profile_name = getattr(request, "profile_name", "") diff --git a/laser_control/models.py b/laser_control/models.py index 381c3bd..0b12353 100644 --- a/laser_control/models.py +++ b/laser_control/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from datetime import datetime -from enum import IntFlag +from enum import IntEnum, IntFlag from typing import Any from .constants import ( @@ -119,6 +119,26 @@ class DeviceStatus: } +class SweepTarget(IntEnum): + """Output driven by a firmware staircase sweep.""" + + LASER2_CURRENT = 0 + STM32_DAC = 1 + + +@dataclass(frozen=True, slots=True) +class SweepSettings: + """Firmware sweep: min..max in fixed steps, then a pause at min before repeating.""" + + target: SweepTarget + enabled: bool + min_code: int + max_code: int + step_code: int + step_period_us: int + pause_ms: int + + @dataclass(slots=True) class ProfileSaveRequest: """Rendered profile payload that should be persisted on the device SD card.""" diff --git a/laser_control/protocol.py b/laser_control/protocol.py index 1211177..129be75 100644 --- a/laser_control/protocol.py +++ b/laser_control/protocol.py @@ -29,6 +29,7 @@ from .constants import ( CMD_DS1809_CONTROL, CMD_STATE, CMD_STM32_DAC_CONTROL, + CMD_SWEEP_CONTROL, CMD_TEC_MODULATION_CONTROL, CMD_TRANS_ENABLE, DEFAULT_SETUP_WORD, @@ -47,6 +48,9 @@ from .constants import ( SEND_PARAMS_TOTAL_LENGTH, SHORT_CONTROL_TOTAL_LENGTH, STM32_DAC_FLAG_ENABLE, + SWEEP_CONTROL_TOTAL_LENGTH, + SWEEP_FLAG_ENABLE, + SWEEP_FLAG_TARGET_STM32_DAC, TEC_MODULATION_FLAG_CHANNEL_2, TEC_MODULATION_FLAG_ENABLE, STATUS_DESCRIPTIONS, @@ -65,7 +69,7 @@ from .conversions import ( voltage_7v_n_to_v, ) from .exceptions import CRCError, ProtocolError -from .models import DeviceState, Measurements +from .models import DeviceState, Measurements, SweepSettings, SweepTarget def _int_to_hex4(value: int) -> str: @@ -293,6 +297,21 @@ class Protocol: _ensure_uint(amplitude_code, "amplitude_code", 0, 0xFFFF), ) + @staticmethod + def encode_sweep_control(settings: SweepSettings) -> bytes: + """Build a laser-2-current or STM32-DAC staircase sweep control packet.""" + flags = SWEEP_FLAG_ENABLE if settings.enabled else 0 + if settings.target == SweepTarget.STM32_DAC: + flags |= SWEEP_FLAG_TARGET_STM32_DAC + payload = [flags] + [ + _ensure_uint(getattr(settings, name), name, 0, 0xFFFF) + for name in ("min_code", "max_code", "step_code", "step_period_us", "pause_ms") + ] + packet = _pack_words([CMD_SWEEP_CONTROL, *payload, _payload_checksum(payload)]) + if len(packet) != SWEEP_CONTROL_TOTAL_LENGTH: + raise ProtocolError(f"Sweep control length mismatch: {len(packet)} bytes") + return packet + @staticmethod def encode_ad9102_wave_begin(sample_count: int, pat_period_us: int = 0) -> bytes: """Build an AD9102 custom-wave upload BEGIN packet.