diff --git a/README.md b/README.md index a9d0537..f172a8c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ PyQt6-приложение для управления лазерной плат - ручной режим: `T1/T2/I1/I2` - live telemetry: `T1/T2`, внешние термисторы, фотодиоды, `3V3/5V1/5V2/7V0` +- TEC drive modulation: синусоидальная добавка к выходу TEC PID для выбранного лазера - AD9102: saw/SRAM режимы и загрузка custom waveform - AD9833, DS1809 и STM32 DAC через отдельные firmware-команды - сохранение профиля на SD-карту устройства diff --git a/laser_control/constants.py b/laser_control/constants.py index 650a77a..ba483d2 100644 --- a/laser_control/constants.py +++ b/laser_control/constants.py @@ -34,6 +34,7 @@ CMD_STM32_DAC_CONTROL = 0xBBBB CMD_AD9102_WAVE_CONTROL = 0xCCCC CMD_AD9102_WAVE_DATA = 0xDDDD CMD_PROFILE_SAVE_DATA = 0xEEEE +CMD_TEC_MODULATION_CONTROL = 0xF0F0 # ---- Setup-word bit layout from firmware app_decode_work_packet() @@ -102,6 +103,9 @@ DS1809_FLAG_DECREMENT = 0x0002 STM32_DAC_FLAG_ENABLE = 0x0001 +TEC_MODULATION_FLAG_ENABLE = 0x0001 +TEC_MODULATION_FLAG_CHANNEL_2 = 0x0002 + AD9102_WAVE_OPCODE_BEGIN = 0x0001 AD9102_WAVE_OPCODE_COMMIT = 0x0002 AD9102_WAVE_OPCODE_CANCEL = 0x0003 @@ -184,6 +188,11 @@ DS1809_PROFILE_POSITION_MAX = 63 STM32_DAC_CODE_MIN = 0 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 + # ---- Rail tolerances VOLT_3V3_MIN = 3.1 @@ -218,6 +227,8 @@ DEFAULT_STM32_DAC_VREF = 2.5 DEFAULT_STM32_DAC_CODE = round( DEFAULT_STM32_DAC_VOLT / DEFAULT_STM32_DAC_VREF * STM32_DAC_CODE_MAX ) +DEFAULT_TEC_MODULATION_FREQUENCY_HZ = 1_000 +DEFAULT_TEC_MODULATION_AMPLITUDE_CODE = 256 DEFAULT_PI_P = 2560 DEFAULT_PI_I = 128 diff --git a/laser_control/controller.py b/laser_control/controller.py index 6799eac..4b92bb7 100644 --- a/laser_control/controller.py +++ b/laser_control/controller.py @@ -50,6 +50,10 @@ from .constants import ( STM32_DAC_CODE_MIN, STATUS_RESPONSE_LENGTH, WAIT_AFTER_SEND_SEC, + TEC_MODULATION_AMPLITUDE_CODE_MAX, + TEC_MODULATION_AMPLITUDE_CODE_MIN, + TEC_MODULATION_FREQUENCY_MAX_HZ, + TEC_MODULATION_FREQUENCY_MIN_HZ, ) from .exceptions import ( CommunicationError, @@ -429,6 +433,44 @@ class LaserController: ) logger.info("STM32 DAC configured: enabled=%s code=%d", enabled, dac_code) + def configure_tec_modulation( + self, + *, + enabled: bool, + laser: int, + frequency_hz: int, + amplitude_code: int, + ) -> None: + """Configure fast zero-mean TEC drive modulation around the PID output.""" + laser = self._validate_int_range(laser, "laser", 1, 2) + frequency_hz = self._validate_int_range( + frequency_hz, + "frequency_hz", + TEC_MODULATION_FREQUENCY_MIN_HZ, + TEC_MODULATION_FREQUENCY_MAX_HZ, + ) + amplitude_code = self._validate_int_range( + amplitude_code, + "amplitude_code", + TEC_MODULATION_AMPLITUDE_CODE_MIN, + TEC_MODULATION_AMPLITUDE_CODE_MAX, + ) + self._send_and_expect_ok( + Protocol.encode_tec_modulation_control( + enabled=enabled, + laser=laser, + frequency_hz=frequency_hz, + amplitude_code=amplitude_code, + ) + ) + logger.info( + "TEC modulation configured: enabled=%s laser=%d frequency_hz=%d amplitude_code=%d", + enabled, + laser, + frequency_hz, + amplitude_code, + ) + 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 63f1235..6d7e428 100644 --- a/laser_control/gui/sections.py +++ b/laser_control/gui/sections.py @@ -51,6 +51,8 @@ from laser_control.constants import ( DEFAULT_DS1809_PROFILE_POSITION, DEFAULT_DS1809_PULSE_MS, DEFAULT_STM32_DAC_CODE, + DEFAULT_TEC_MODULATION_AMPLITUDE_CODE, + DEFAULT_TEC_MODULATION_FREQUENCY_HZ, DEFAULT_TEMP1_C, DEFAULT_TEMP2_C, DS1809_COUNT_MAX, @@ -64,6 +66,10 @@ from laser_control.constants import ( AD9833_OUTPUT_FREQ_MIN_HZ, STM32_DAC_CODE_MAX, STM32_DAC_CODE_MIN, + TEC_MODULATION_AMPLITUDE_CODE_MAX, + TEC_MODULATION_AMPLITUDE_CODE_MIN, + TEC_MODULATION_FREQUENCY_MAX_HZ, + TEC_MODULATION_FREQUENCY_MIN_HZ, TEMP_MAX_C, TEMP_MIN_C, ) @@ -151,6 +157,7 @@ def build_device_group(owner) -> QGroupBox: tabs.addTab(_build_ad9102_tab(owner), "Генератор AD9102") 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_wave_tab(owner), "Своя форма") layout.addWidget(tabs) return group @@ -448,6 +455,54 @@ def _build_aux_tab(owner) -> QWidget: return tab +def _build_tec_modulation_tab(owner) -> QWidget: + tab = QWidget() + layout = QFormLayout(tab) + layout.setHorizontalSpacing(12) + layout.setVerticalSpacing(8) + + owner._tec_mod_enable = QCheckBox("Включить модуляцию") + owner._tec_mod_laser = QComboBox() + owner._tec_mod_laser.addItem("Лазер 1", 1) + owner._tec_mod_laser.addItem("Лазер 2", 2) + owner._tec_mod_frequency_hz = _int_spinbox( + TEC_MODULATION_FREQUENCY_MIN_HZ, + TEC_MODULATION_FREQUENCY_MAX_HZ, + DEFAULT_TEC_MODULATION_FREQUENCY_HZ, + suffix=" Гц", + ) + owner._tec_mod_frequency_hz.setSingleStep(50) + owner._tec_mod_frequency_hz.setGroupSeparatorShown(True) + owner._tec_mod_amplitude_code = _int_spinbox( + TEC_MODULATION_AMPLITUDE_CODE_MIN, + TEC_MODULATION_AMPLITUDE_CODE_MAX, + DEFAULT_TEC_MODULATION_AMPLITUDE_CODE, + ) + owner._tec_mod_amplitude_code.setSingleStep(16) + 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) + + layout.addRow(owner._tec_mod_enable) + layout.addRow("Лазер", owner._tec_mod_laser) + layout.addRow( + f"Частота ({TEC_MODULATION_FREQUENCY_MIN_HZ}..{TEC_MODULATION_FREQUENCY_MAX_HZ} Гц)", + owner._tec_mod_frequency_hz, + ) + layout.addRow( + f"Амплитуда DAC ({TEC_MODULATION_AMPLITUDE_CODE_MIN}..{TEC_MODULATION_AMPLITUDE_CODE_MAX})", + owner._tec_mod_amplitude_code, + ) + layout.addRow(owner._apply_tec_modulation_button) + + owner._tec_mod_frequency_hz.setToolTip("Частота синусоидальной добавки к выходу TEC PID.") + owner._tec_mod_amplitude_code.setToolTip( + "Пиковая амплитуда добавки в кодах внешнего TEC DAC. " + "Прошивка ограничивает её по доступному запасу вокруг текущего PID-кода." + ) + 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 1d69639..117c193 100644 --- a/laser_control/gui/window.py +++ b/laser_control/gui/window.py @@ -66,6 +66,7 @@ class MainWindow(QMainWindow): request_apply_ad9833 = pyqtSignal(bool, bool, int) request_pulse_ds1809 = pyqtSignal(bool, int, int) request_set_stm32_dac = pyqtSignal(bool, int) + request_apply_tec_modulation = pyqtSignal(bool, int, int, int) request_upload_wave = pyqtSignal(object) request_cancel_wave = pyqtSignal() request_save_profile = pyqtSignal(object) @@ -219,6 +220,7 @@ class MainWindow(QMainWindow): self.request_apply_ad9833.connect(self._worker.apply_ad9833) 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_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) @@ -320,6 +322,16 @@ class MainWindow(QMainWindow): ) ) + def _on_apply_tec_modulation(self) -> None: + self._dispatch_command( + lambda: self.request_apply_tec_modulation.emit( + self._tec_mod_enable.isChecked(), + int(self._tec_mod_laser.currentData()), + self._tec_mod_frequency_hz.value(), + self._tec_mod_amplitude_code.value(), + ) + ) + def _on_save_profile(self) -> None: dialog = ProfileSaveDialog( custom_waveform_available=self._custom_waveform_is_available(), @@ -408,6 +420,7 @@ class MainWindow(QMainWindow): self._apply_ad9102_button.setEnabled(connected) self._apply_ad9833_button.setEnabled(connected) self._apply_stm32_dac_button.setEnabled(connected) + self._apply_tec_modulation_button.setEnabled(connected) self._pulse_ds1809_button.setEnabled(connected) self._upload_wave_button.setEnabled(connected) self._cancel_wave_button.setEnabled(connected) @@ -724,6 +737,11 @@ class MainWindow(QMainWindow): f"stm32_dac_enable={1 if self._stm32_dac_enable.isChecked() else 0}", f"stm32_dac_code={self._stm32_dac_code.value()}", "", + f"tec_modulation_enable={1 if self._tec_mod_enable.isChecked() else 0}", + f"tec_modulation_laser={int(self._tec_mod_laser.currentData())}", + f"tec_modulation_frequency_hz={self._tec_mod_frequency_hz.value()}", + f"tec_modulation_amplitude_code={self._tec_mod_amplitude_code.value()}", + "", f"ds1809_apply={'true' if self._ds1809_profile_apply.isChecked() else 'false'}", f"ds1809_position_from_min={self._ds1809_profile_position.value()}", ] diff --git a/laser_control/gui/worker.py b/laser_control/gui/worker.py index 4197d9a..c6bcb0f 100644 --- a/laser_control/gui/worker.py +++ b/laser_control/gui/worker.py @@ -101,6 +101,22 @@ class ControllerWorker(QObject): ) ) + @pyqtSlot(bool, int, int, int) + def apply_tec_modulation( + self, + enabled: bool, + laser: int, + frequency_hz: int, + amplitude_code: int, + ) -> None: + """Configure fast TEC drive modulation.""" + self._run_command( + lambda: ( + self._ensure_connected(), + self._apply_tec_modulation_impl(enabled, laser, frequency_hz, amplitude_code), + ) + ) + @pyqtSlot(object) def save_profile(self, request: object) -> None: """Save the current GUI configuration to the device SD card.""" @@ -256,6 +272,26 @@ class ControllerWorker(QObject): self.log_message.emit("INFO", f"STM32 DAC set to code {dac_code}") self._emit_status() + def _apply_tec_modulation_impl( + self, + enabled: bool, + laser: int, + frequency_hz: int, + amplitude_code: int, + ) -> None: + self._controller.configure_tec_modulation( + enabled=enabled, + laser=laser, + frequency_hz=frequency_hz, + amplitude_code=amplitude_code, + ) + state = "enabled" if enabled else "disabled" + self.log_message.emit( + "INFO", + f"TEC modulation {state}: laser={laser}, frequency={frequency_hz} Hz, amplitude={amplitude_code}", + ) + 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/protocol.py b/laser_control/protocol.py index 949433a..5ee265f 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_TEC_MODULATION_CONTROL, CMD_TRANS_ENABLE, DEFAULT_SETUP_WORD, DS1809_FLAG_DECREMENT, @@ -46,6 +47,8 @@ from .constants import ( SEND_PARAMS_TOTAL_LENGTH, SHORT_CONTROL_TOTAL_LENGTH, STM32_DAC_FLAG_ENABLE, + TEC_MODULATION_FLAG_CHANNEL_2, + TEC_MODULATION_FLAG_ENABLE, STATUS_DESCRIPTIONS, STATUS_RESPONSE_LENGTH, WAVE_DATA_TOTAL_LENGTH, @@ -270,6 +273,25 @@ class Protocol: 0, ) + @staticmethod + def encode_tec_modulation_control( + *, + enabled: bool, + laser: int, + frequency_hz: int, + amplitude_code: int, + ) -> bytes: + """Build a TEC drive-modulation control packet.""" + flags = TEC_MODULATION_FLAG_ENABLE if enabled else 0 + if laser == 2: + flags |= TEC_MODULATION_FLAG_CHANNEL_2 + return Protocol._encode_short_control( + CMD_TEC_MODULATION_CONTROL, + flags, + _ensure_uint(frequency_hz, "frequency_hz", 0, 0xFFFF), + _ensure_uint(amplitude_code, "amplitude_code", 0, 0xFFFF), + ) + @staticmethod def encode_ad9102_wave_begin(sample_count: int) -> bytes: """Build an AD9102 custom-wave upload BEGIN packet."""