wip(temperature_controller, scheduler)
CI for debye_bec / test (push) Failing after 47s
CI for debye_bec / test (pull_request) Failing after 48s

This commit is contained in:
hitz_s committed 2026-11-02 11:05:25 +01:00
1 parent 0f72f290b4
commit 317e52a1bc
5 files changed
+243 -68

No files matched your search

@@ -73,12 +73,14 @@ class ScheduleItemDialog(QDialog):
from ..digital_twin.digital_twin import DigitalTwin
from ..edge_selector import EdgeSelector
from ..scan_control_xas.scan_control_xas import ScanControlXAS
from ..temperature_controller.temperature_controller import TemperatureControllerControl
self.DigitalTwinCore = DigitalTwinCore
self.DigitalTwin = DigitalTwin
self.AutoGain = AutoGain
self.EdgeSelector = EdgeSelector
self.ScanControlXAS = ScanControlXAS
self.TemperatureController = TemperatureControllerControl
layout = QVBoxLayout(self)
@@ -96,6 +98,7 @@ class ScheduleItemDialog(QDialog):
self._build_ic_page()
self._build_reffoil_page()
self._build_auto_gain_page()
self._build_temperature_controller_page()
self._build_other_page()
self.item_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
@@ -118,6 +121,7 @@ class ScheduleItemDialog(QDialog):
"ic": self._collect_ic_result,
"reffoil": self._collect_reffoil_result,
"auto_gain": self._collect_auto_gain_result,
"temperautre_control": self._collect_temperature_controller_result,
"other": self._collect_custom_result,
}
@@ -535,6 +539,69 @@ class ScheduleItemDialog(QDialog):
def _prefill_auto_gain_form(self, state: dict):
self.auto_gain.load_config(state["config"])
# ------------------------------------------------------------------ #
# Temperature Controller
# ------------------------------------------------------------------ #
def _build_tempature_controller_page(self):
page = QWidget()
outer = QVBoxLayout(page)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
self.temperature_controller = self.TemperatureController(config_only=True, parent=scroll, client=self._client)
scroll.setWidget(self.temperature_controller)
outer.addWidget(scroll)
# hint = QLabel(
# "Configure the auto-gain functionality, then confirm with Add below - the "
# "auto-gain will only be executed when this schedule item runs, not immediately."
# )
# hint.setWordWrap(True)
# hint.setStyleSheet("color: gray;")
# outer.addWidget(hint)
self._add_page("temperature_controller", "Temperature Controller", page)
def _collect_temperature_controller_result(self) -> dict:
config = self.temperature_controller.get_config()
cmd = f"dev.univ_temp_cont.load_config(config={config})"
return {
"command": cmd,
"kind": "custom",
"form_state": {"text": cmd, "source": "temperature_controller", "config": config},
}
def _prefill_temperature_controller_form(self, state: dict):
self.temperature_controller.load_config(state["config"])
# ------------------------------------------------------------------ #
# Sleep
# ------------------------------------------------------------------ #
def _build_sleep_page(self):
page = QWidget()
form = QFormLayout(page)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint)
self.sleep_spin = QDoubleSpinBox(suffix=" s")
form.addRow("Sleep for", self.sleep_spin)
self._add_page("sleep", "Sleep", page)
def _collect_sleep_result(self) -> dict:
value = self.sleep_spin.value()
return {
"command": f"time.sleep({value})",
"kind": "sleep",
"form_state": {
"value": value,
},
}
def _prefill_sleep_page(self, state: dict):
self.sleep_spin.setValue(float(state.get("value", 0.0)))
# ------------------------------------------------------------------ #
# Other
# ------------------------------------------------------------------ #
@@ -591,6 +658,7 @@ class ScheduleItemDialog(QDialog):
self._prefill_reffoil_form(state)
elif kind == "custom" and source == "auto_gain" and self._select_page("auto_gain"):
self._prefill_auto_gain_form(state)
elif kind == "custom" and source == "temperature_controller" and self._select_page("temperature_controller")
elif initial.get("command"):
# "custom" kind with no (recognized/available) source, or a
# legacy/unrecognized item - fall back to showing the raw
@@ -143,6 +143,18 @@ def build_item_display_text(item: ScheduleItem, beamline: str | None) -> str:
+ "}"
)
text += "\nProhibited gains: " + prohibited_gains
elif item.command.startswith("dev.univ_temp_cont.load_config("):
config = item.form_state["config"]
match config["power_mode"]:
case "Standby":
text += "Switch temperature controller to standby"
case "Configuration":
text += "Switch temperature controller to configuration mode"
case _:
text += (
f"Set temperature controller target setpoint to {config['target_setpoint']}"
+ f" °C\nwith ramp rate of {config['rampe_rate']} {config['ramp_rate_unit']}"
)
elif item.command.startswith("dev.abs."):
action = item.form_state["action"]
text += f"{action} Absorber"
@@ -171,6 +171,9 @@ class ComboBox(QWidget):
if not QApplication.focusWidget() is self.value.view():
self.value.setCurrentIndex(index)
def currentIndex(self):
return self.value.currentIndex()
def currentText(self) -> str:
return self.value.currentText()
@@ -5,6 +5,7 @@ from __future__ import annotations
import sys
import time
from collections import deque
from typing import Literal
import numpy as np
import pyqtgraph as pg
@@ -15,11 +16,16 @@ from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from pydantic import BaseModel
from PySide6.QtCore import QLocale, QPoint, QRect, QSize, Qt
from PySide6.QtGui import QCursor, QFont, QIcon, QPalette, QRegion
from PySide6.QtWidgets import QSizePolicy
# pylint: disable=E0611
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QVBoxLayout, QWidget
from ....devices.univ_temp_cont.univ_temp_cont import ConfigDict
from ....devices.univ_temp_cont.univ_temp_cont_enums import (
POWER_MAP,
RAMP_RATE_MAP,
@@ -48,6 +54,61 @@ class TemperatureControllerError(Exception):
"""Temperature Controller specific error"""
class TemperatureControllerControl(QWidget):
def __init__(self, config_only: bool = False, *arg, parent=None, **kwargs) -> None:
super().__init__(*arg, parent=parent, **kwargs)
self._layout = QVBoxLayout(self)
self.power_mode = ComboBox("power", "Power mode", POWER_MAP.keys())
self.target_setpoint = InputNumberField(
"target_setpoint", "Target setpoint", "°C", decimals=0, single_step=1, ll=0, hl=850
)
self.ramp_rate = InputNumberField(
"ramp_rate", "Ramp rate", decimals=0, single_step=1, ll=0, hl=1e5
)
self.ramp_rate_unit = ComboBox("ramp_rate_unit", "Ramp rate unit", RAMP_RATE_MAP.keys())
if not config_only:
self.setpoint = NumberIndicator("Setpoint", "°C", highlight=True, decimals=0)
widgets = [
self.power_mode,
self.target_setpoint,
self.setpoint,
self.ramp_rate,
self.ramp_rate_unit,
]
else:
widgets = [self.power_mode, self.target_setpoint, self.ramp_rate, self.ramp_rate_unit]
control_group = Group("Control", widgets)
self.ramp_rate_unit.activated_connect(self._on_ramp_rate_unit_change)
self._on_ramp_rate_unit_change()
self._layout.addWidget(control_group)
@SafeSlot()
def _on_ramp_rate_unit_change(self, *_, **__):
index = self.ramp_rate_unit.currentIndex()
self.ramp_rate.setUnit(
list(RAMP_RATE_MAP.keys())[list(RAMP_RATE_MAP.values()).index(index)]
)
def get_config(self) -> ConfigDict:
return ConfigDict(
power_mode=self.power_mode.currentText(),
target_setpoint=self.target_setpoint.value(),
ramp_rate=self.ramp_rate.value(),
ramp_rate_unit=self.ramp_rate_unit.currentText(),
)
def load_config(self, config: ConfigDict) -> None:
self.power_mode.setCurrentIndex(POWER_MAP[config.power_mode])
self.ramp_rate_unit.setCurrentIndex(RAMP_RATE_MAP[config.ramp_rate_unit])
self.ramp_rate.set_number(config.ramp_rate)
self.target_setpoint.set_number(config.target_setpoint)
class TemperatureController(BECWidget, QWidget):
"""Waveform with predefined curves and one status label."""
@@ -65,6 +126,42 @@ class TemperatureController(BECWidget, QWidget):
self._window_s = DEFAULT_WINDOW_S
self.init_ui()
self._buffers: dict[str, deque] = {}
self._curves = {}
for signal in PLOT_SIGNALS:
self._buffers[signal] = deque() # (timestamp, value), trimmed by time
self._curves[signal] = self.waveform.plot(
x=[], y=[], label=getattr(self.dev.univ_temp_cont, signal).full_name
)
self._curves[signal].set_symbol(None)
self.bec_dispatcher.connect_slot(
self._on_readback, MessageEndpoints.device_readback(DEVICE_NAME)
)
self._refresh_timer = QTimer(self)
self._refresh_timer.timeout.connect(self._redraw)
self._refresh_timer.start(REFRESH_MS)
self._config_signal_timer = QTimer(self)
self._config_signal_timer.timeout.connect(self._config_signal_read)
self._config_signal_timer.start(READ_CONFIG_SIGNAL_MS)
self.control.power_mode.activated_connect(self._on_value_changed)
self.control.target_setpoint.editing_finished_connect(self._on_value_changed)
self.control.ramp_rate.editing_finished_connect(self._on_value_changed)
self.control.ramp_rate_unit.activated_connect(self._on_value_changed)
self.pid_pb.editing_finished_connect(self._on_value_changed)
self.pid_ti.editing_finished_connect(self._on_value_changed)
self.pid_td.editing_finished_connect(self._on_value_changed)
self.autotune.clicked_connect(self._start_stop_autotune)
self.thermocouple.activated_connect(self._on_value_changed)
self.plot_init = True
def init_ui(self) -> None:
"""Init the UI for the temperature controller widget, should only take place once."""
root_layout = QHBoxLayout(self)
control_layout = QVBoxLayout()
@@ -90,26 +187,7 @@ class TemperatureController(BECWidget, QWidget):
],
)
self.power_mode = ComboBox("power", "Power mode", POWER_MAP.keys())
self.target_setpoint = InputNumberField(
"target_setpoint", "Target setpoint", "°C", decimals=0, single_step=1, ll=0, hl=850
)
self.setpoint = NumberIndicator("Setpoint", "°C", highlight=True, decimals=0)
self.ramp_rate = InputNumberField(
"ramp_rate", "Ramp rate", decimals=0, single_step=1, ll=0, hl=1e5
)
self.ramp_rate_unit = ComboBox("ramp_rate_unit", "Ramp rate unit", RAMP_RATE_MAP.keys())
control_group = Group(
"Control",
[
self.power_mode,
self.target_setpoint,
self.setpoint,
self.ramp_rate,
self.ramp_rate_unit,
],
)
self.control = TemperatureControllerControl()
self.pid_pb = InputNumberField("pb", "PB", decimals=0, single_step=1, ll=1, hl=9999)
self.pid_ti = InputNumberField(
@@ -132,7 +210,7 @@ class TemperatureController(BECWidget, QWidget):
configuration_group = Group("Configuration", [self.tc_info, self.thermocouple])
control_layout.addWidget(status_group)
control_layout.addWidget(control_group)
control_layout.addWidget(self.control)
control_layout.addWidget(pid_loop_group)
control_layout.addWidget(configuration_group)
control_layout.addStretch()
@@ -141,10 +219,6 @@ class TemperatureController(BECWidget, QWidget):
waveform_layout = QVBoxLayout()
self.waveform = Waveform(parent=self, client=self.client, popups=True)
waveform_layout.addWidget(self.waveform, stretch=1)
waveform_settings_layout = QHBoxLayout()
self.user_window_s = InputNumberField(
"",
@@ -161,6 +235,10 @@ class TemperatureController(BECWidget, QWidget):
waveform_layout.addLayout(waveform_settings_layout)
self.waveform = Waveform(parent=self, client=self.client, popups=True)
waveform_layout.addWidget(self.waveform, stretch=1)
root_layout.addLayout(waveform_layout, stretch=1)
self.waveform.set(title="", x_label="Time")
@@ -168,38 +246,6 @@ class TemperatureController(BECWidget, QWidget):
self.waveform.enable_side_panel = False
self.waveform.plot_item.setAxisItems({"bottom": pg.DateAxisItem(orientation="bottom")})
self._buffers: dict[str, deque] = {}
self._curves = {}
for signal in PLOT_SIGNALS:
self._buffers[signal] = deque() # (timestamp, value), trimmed by time
self._curves[signal] = self.waveform.plot(
x=[], y=[], label=getattr(self.dev.univ_temp_cont, signal).full_name
)
self._curves[signal].set_symbol(None)
self.bec_dispatcher.connect_slot(
self._on_readback, MessageEndpoints.device_readback(DEVICE_NAME)
)
self._refresh_timer = QTimer(self)
self._refresh_timer.timeout.connect(self._redraw)
self._refresh_timer.start(REFRESH_MS)
self._config_signal_timer = QTimer(self)
self._config_signal_timer.timeout.connect(self._config_signal_read)
self._config_signal_timer.start(READ_CONFIG_SIGNAL_MS)
self.power_mode.activated_connect(self._on_value_changed)
self.target_setpoint.editing_finished_connect(self._on_value_changed)
self.ramp_rate.editing_finished_connect(self._on_value_changed)
self.ramp_rate_unit.activated_connect(self._on_value_changed)
self.pid_pb.editing_finished_connect(self._on_value_changed)
self.pid_ti.editing_finished_connect(self._on_value_changed)
self.pid_td.editing_finished_connect(self._on_value_changed)
self.autotune.clicked_connect(self._start_stop_autotune)
self.thermocouple.activated_connect(self._on_value_changed)
self.plot_init = True
@SafeSlot()
def apply_theme(self, theme):
self.sensor_break.apply_theme()
@@ -209,6 +255,20 @@ class TemperatureController(BECWidget, QWidget):
self.autotune.apply_theme()
return super().apply_theme(theme)
def load_config(self, config: ConfigDict) -> None:
# Power mode
signal = getattr(self.dev.univ_temp_cont, "power")
signal.put(POWER_MAP[config.power_mode])
# Ramp rate unit
signal = getattr(self.dev.univ_temp_cont, "ramp_rate_unit")
signal.put(RAMP_RATE_MAP[config.ramp_rate_unit])
# Ramp rate
signal = getattr(self.dev.univ_temp_cont, "ramp_rate")
signal.put(config.ramp_rate)
# Target setpoint
signal = getattr(self.dev.univ_temp_cont, "target_setpoint")
signal.put(config.target_setpoint)
@SafeSlot()
def _on_value_changed(self, *_, **kwargs):
if "index" in kwargs.keys():
@@ -286,22 +346,19 @@ class TemperatureController(BECWidget, QWidget):
case _:
raise TemperatureControllerError(f"Not implemented op_mode value {value}")
index = signals[DEVICE_NAME + "_" + "power"]["value"]
self.power_mode.setCurrentIndex(index)
self.control.power_mode.setCurrentIndex(index)
value = signals[DEVICE_NAME + "_" + "target_setpoint"]["value"]
self.target_setpoint.set_number(value)
self.control.target_setpoint.set_number(value)
value = signals[DEVICE_NAME + "_" + "setpoint"]["value"]
self.setpoint.setValue(value)
self.control.setpoint.setValue(value)
value = signals[DEVICE_NAME + "_" + "ramp_rate"]["value"]
self.ramp_rate.set_number(value)
self.control.ramp_rate.set_number(value)
def _config_signal_read(self):
# Ramp rate unit
value = self.dev.univ_temp_cont.ramp_rate_unit.read()
index = value[DEVICE_NAME + "_ramp_rate_unit"]["value"]
self.ramp_rate_unit.setCurrentIndex(index)
self.ramp_rate.setUnit(
list(RAMP_RATE_MAP.keys())[list(RAMP_RATE_MAP.values()).index(index)]
)
self.control.ramp_rate_unit.setCurrentIndex(index)
# PB
value = self.dev.univ_temp_cont.pb.read()[DEVICE_NAME + "_pb"]["value"]
self.pid_pb.set_number(value)
@@ -359,6 +416,6 @@ if __name__ == "__main__":
app = QApplication(sys.argv)
apply_theme("light")
dispatcher = BECDispatcher(gui_id="restart_server")
win = TemperatureController()
win = TemperatureControllerControl(config_only=True)
win.show()
sys.exit(app.exec_())
@@ -2,13 +2,14 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Literal
from ophyd import Component as Cpt
from ophyd import EpicsSignal, EpicsSignalRO
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from pydantic import BaseModel
from .univ_temp_cont_enums import UnivTempContSetpointSelect
from .univ_temp_cont_enums import POWER_MAP, RAMP_RATE_MAP, UnivTempContSetpointSelect
if TYPE_CHECKING:
from bec_lib.devicemanager import ScanInfo
@@ -16,9 +17,20 @@ if TYPE_CHECKING:
TIMEOUT_FOR_PV = 5
class ConfigDict(BaseModel):
"""A configuration dictionary."""
power_mode: Literal[tuple(POWER_MAP)] # type: ignore[valid-type]
target_setpoint: float
ramp_rate: float
ramp_rate_unit: Literal[tuple(RAMP_RATE_MAP)] # type: ignore[valid-type]
class UnivTempCont(PSIDeviceBase):
"""Class for the Universal Temperature Controller"""
USER_ACCESS = ["load_config"]
temperature = Cpt(
EpicsSignalRO, suffix="TEMPERATURE", kind="normal", auto_monitor=True, doc="Temperature"
)
@@ -81,3 +93,26 @@ class UnivTempCont(PSIDeviceBase):
self.setpoint_select.set(UnivTempContSetpointSelect.REMOTE).wait(
timeout=self.timeout_for_pvwait
)
def load_config(
self,
config: ConfigDict | None = None,
power_mode: str = "",
ramp_rate_unit: str = "",
ramp_rate: float = 0,
target_setpoint: float = 0,
) -> None:
if config is None:
config = ConfigDict(
power_mode=power_mode,
ramp_rate_unit=ramp_rate_unit,
ramp_rate=ramp_rate,
target_setpoint=target_setpoint,
)
# Load a config to the controller (power, setpoint and ramp rate)
self.power.set(POWER_MAP[config.power_mode]).wait(timeout=self.timeout_for_pvwait)
self.ramp_rate_unit.set(RAMP_RATE_MAP[config.ramp_rate_unit]).wait(
timeout=self.timeout_for_pvwait
)
self.ramp_rate.set(config.ramp_rate).wait(timeout=self.timeout_for_pvwait)
self.target_setpoint.set(config.target_setpoint).wait(timeout=self.timeout_for_pvwait)