From 0d58d70607a9f86cb5f85b14057dca819716de4f Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 4 Nov 2024 13:09:01 +0800 Subject: [PATCH] PyThermostat GUI: Implement Control Panel Co-authored-by: linuswck Co-authored-by: Egor Savkin --- .../pythermostat/gui/view/ctrl_panel.py | 306 ++++++++++++++++ .../pythermostat/gui/view/param_tree.json | 336 ++++++++++++++++++ pythermostat/pythermostat/thermostat_qt.py | 25 +- 3 files changed, 665 insertions(+), 2 deletions(-) create mode 100644 pythermostat/pythermostat/gui/view/ctrl_panel.py create mode 100644 pythermostat/pythermostat/gui/view/param_tree.json diff --git a/pythermostat/pythermostat/gui/view/ctrl_panel.py b/pythermostat/pythermostat/gui/view/ctrl_panel.py new file mode 100644 index 0000000..8ceed8c --- /dev/null +++ b/pythermostat/pythermostat/gui/view/ctrl_panel.py @@ -0,0 +1,306 @@ +from functools import partial +from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot +import pyqtgraph.parametertree.parameterTypes as pTypes +from pyqtgraph.parametertree import ( + Parameter, + registerParameterType, +) +from qasync import asyncSlot +from autotune import PIDAutotuneState + + +class MutexParameter(pTypes.ListParameter): + """ + Mutually exclusive parameter where only one of its children is visible at a time, list selectable. + + The ordering of the list items determines which children will be visible. + """ + + def __init__(self, **opts): + super().__init__(**opts) + + self.sigValueChanged.connect(self.show_chosen_child) + self.sigValueChanged.emit(self, self.opts["value"]) + + def _get_param_from_value(self, value): + if isinstance(self.opts["limits"], dict): + values_list = list(self.opts["limits"].values()) + else: + values_list = self.opts["limits"] + + return self.children()[values_list.index(value)] + + @pyqtSlot(object, object) + def show_chosen_child(self, value): + for param in self.children(): + param.hide() + + child_to_show = self._get_param_from_value(value.value()) + child_to_show.show() + + if child_to_show.opts.get("triggerOnShow", None): + child_to_show.sigValueChanged.emit(child_to_show, child_to_show.value()) + + +registerParameterType("mutex", MutexParameter) + + +class CtrlPanel(QObject): + def __init__( + self, + thermostat, + autotuners, + info_box, + trees_ui, + param_tree, + parent=None, + ): + super().__init__(parent) + + self.thermostat = thermostat + self.autotuners = autotuners + self.info_box = info_box + self.trees_ui = trees_ui + self.NUM_CHANNELS = len(trees_ui) + + self.THERMOSTAT_PARAMETERS = [param_tree for i in range(self.NUM_CHANNELS)] + + self.params = [ + Parameter.create( + name=f"Thermostat Channel {ch} Parameters", + type="group", + value=ch, + children=self.THERMOSTAT_PARAMETERS[ch], + ) + for ch in range(self.NUM_CHANNELS) + ] + + for i, param in enumerate(self.params): + param.channel = i + + for i, tree in enumerate(self.trees_ui): + tree.setHeaderHidden(True) + tree.setParameters(self.params[i], showTop=False) + self.params[i].setValue = self._setValue + self.params[i].sigTreeStateChanged.connect(self.send_command) + + self.params[i].child("Save to flash").sigActivated.connect( + partial(self.save_settings, i) + ) + self.params[i].child("Load from flash").sigActivated.connect( + partial(self.load_settings, i) + ) + self.params[i].child( + "PID Config", "PID Auto Tune", "Run" + ).sigActivated.connect(partial(self.pid_auto_tune_request, i)) + + self.thermostat.pid_update.connect(self.update_pid) + self.thermostat.report_update.connect(self.update_report) + self.thermostat.thermistor_update.connect(self.update_thermistor) + self.thermostat.output_update.connect(self.update_output) + self.thermostat.postfilter_update.connect(self.update_postfilter) + self.autotuners.autotune_state_changed.connect(self.update_pid_autotune) + + def _setValue(self, value, blockSignal=None): + """ + Implement 'lock' mechanism for Parameter Type + + Modified from the source + """ + try: + if blockSignal is not None: + self.sigValueChanged.disconnect(blockSignal) + value = self._interpretValue(value) + if fn.eq(self.opts["value"], value): + return value + + if "lock" in self.opts.keys(): + if self.opts["lock"]: + return value + self.opts["value"] = value + self.sigValueChanged.emit( + self, value + ) # value might change after signal is received by tree item + finally: + if blockSignal is not None: + self.sigValueChanged.connect(blockSignal) + + return self.opts["value"] + + def change_params_title(self, channel, path, title): + self.params[channel].child(*path).setOpts(title=title) + + @asyncSlot(object, object) + async def send_command(self, param, changes): + """Translates parameter tree changes into thermostat set_param calls""" + ch = param.channel + + for inner_param, change, data in changes: + if change == "value": + new_value = data + if "thermostat:set_param" in inner_param.opts: + if inner_param.opts.get("suffix", None) == "mA": + new_value /= 1000 # Given in mA + + thermostat_param = inner_param.opts["thermostat:set_param"] + + # Handle thermostat command irregularities + match inner_param.name(), new_value: + case "Postfilter Rate", None: + thermostat_param = thermostat_param.copy() + thermostat_param["field"] = "off" + new_value = "" + case "Control Method", "Constant Current": + return + case "Control Method", "Temperature PID": + new_value = "" + + inner_param.setOpts(lock=True) + await self.thermostat.set_param( + channel=ch, value=new_value, **thermostat_param + ) + inner_param.setOpts(lock=False) + + if "pid_autotune" in inner_param.opts: + auto_tuner_param = inner_param.opts["pid_autotune"] + self.autotuners.set_params(auto_tuner_param, ch, new_value) + + @pyqtSlot(list) + def update_pid(self, pid_settings): + for settings in pid_settings: + channel = settings["channel"] + with QSignalBlocker(self.params[channel]): + self.params[channel].child("PID Config", "Kp").setValue( + settings["parameters"]["kp"] + ) + self.params[channel].child("PID Config", "Ki").setValue( + settings["parameters"]["ki"] + ) + self.params[channel].child("PID Config", "Kd").setValue( + settings["parameters"]["kd"] + ) + self.params[channel].child( + "PID Config", "PID Output Clamping", "Minimum" + ).setValue(settings["parameters"]["output_min"] * 1000) + self.params[channel].child( + "PID Config", "PID Output Clamping", "Maximum" + ).setValue(settings["parameters"]["output_max"] * 1000) + self.params[channel].child( + "Output Config", "Control Method", "Set Temperature" + ).setValue(settings["target"]) + + @pyqtSlot(list) + def update_report(self, report_data): + for settings in report_data: + channel = settings["channel"] + with QSignalBlocker(self.params[channel]): + self.params[channel].child("Output Config", "Control Method").setValue( + "Temperature PID" if settings["pid_engaged"] else "Constant Current" + ) + self.params[channel].child( + "Output Config", "Control Method", "Set Current" + ).setValue(settings["i_set"] * 1000) + if settings["temperature"] is not None: + self.params[channel].child("Temperature").setValue( + settings["temperature"] + ) + if settings["tec_i"] is not None: + self.params[channel].child("Current through TEC").setValue( + settings["tec_i"] * 1000 + ) + + @pyqtSlot(list) + def update_thermistor(self, sh_data): + for sh_param in sh_data: + channel = sh_param["channel"] + with QSignalBlocker(self.params[channel]): + self.params[channel].child("Thermistor Config", "T₀").setValue( + sh_param["params"]["t0"] - 273.15 + ) + self.params[channel].child("Thermistor Config", "R₀").setValue( + sh_param["params"]["r0"] + ) + self.params[channel].child("Thermistor Config", "B").setValue( + sh_param["params"]["b"] + ) + + @pyqtSlot(list) + def update_output(self, output_data): + for output_params in output_data: + channel = output_params["channel"] + with QSignalBlocker(self.params[channel]): + self.params[channel].child( + "Output Config", "Limits", "Max Voltage Difference" + ).setValue(output_params["max_v"]) + self.params[channel].child( + "Output Config", "Limits", "Max Cooling Current" + ).setValue(output_params["max_i_pos"] * 1000) + self.params[channel].child( + "Output Config", "Limits", "Max Heating Current" + ).setValue(output_params["max_i_neg"] * 1000) + + @pyqtSlot(list) + def update_postfilter(self, postfilter_data): + for postfilter_params in postfilter_data: + channel = postfilter_params["channel"] + with QSignalBlocker(self.params[channel]): + self.params[channel].child( + "Thermistor Config", "Postfilter Rate" + ).setValue(postfilter_params["rate"]) + + def update_pid_autotune(self, ch, state): + match state: + case PIDAutotuneState.STATE_OFF: + self.change_params_title( + ch, ("PID Config", "PID Auto Tune", "Run"), "Run" + ) + case ( + PIDAutotuneState.STATE_READY + | PIDAutotuneState.STATE_RELAY_STEP_UP + | PIDAutotuneState.STATE_RELAY_STEP_DOWN + ): + self.change_params_title( + ch, ("PID Config", "PID Auto Tune", "Run"), "Stop" + ) + case PIDAutotuneState.STATE_SUCCEEDED: + self.info_box.display_info_box( + "PID Autotune Success", + f"Channel {ch} PID Config has been loaded to Thermostat. Regulating temperature.", + ) + case PIDAutotuneState.STATE_FAILED: + self.info_box.display_info_box( + "PID Autotune Failed", + f"Channel {ch} PID Autotune has failed.", + ) + + @asyncSlot(int) + async def load_settings(self, ch): + await self.thermostat.load_cfg(ch) + + self.info_box.display_info_box( + f"Channel {ch} settings loaded", + f"Channel {ch} settings has been loaded from flash.", + ) + + @asyncSlot(int) + async def save_settings(self, ch): + await self.thermostat.save_cfg(ch) + + self.info_box.display_info_box( + f"Channel {ch} settings saved", + f"Channel {ch} settings has been saved to flash.\n" + "It will be loaded on Thermostat reset, or when settings are explicitly loaded.", + ) + + @asyncSlot() + async def pid_auto_tune_request(self, ch=0): + match self.autotuners.get_state(ch): + case PIDAutotuneState.STATE_OFF | PIDAutotuneState.STATE_FAILED: + self.autotuners.load_params_and_set_ready(ch) + + case ( + PIDAutotuneState.STATE_READY + | PIDAutotuneState.STATE_RELAY_STEP_UP + | PIDAutotuneState.STATE_RELAY_STEP_DOWN + ): + await self.autotuners.stop_pid_from_running(ch) diff --git a/pythermostat/pythermostat/gui/view/param_tree.json b/pythermostat/pythermostat/gui/view/param_tree.json new file mode 100644 index 0000000..03da4b9 --- /dev/null +++ b/pythermostat/pythermostat/gui/view/param_tree.json @@ -0,0 +1,336 @@ +{ + "ctrl_panel": [ + { + "name": "Temperature", + "type": "float", + "format": "{value:.4f} °C", + "readonly": true + }, + { + "name": "Current through TEC", + "type": "float", + "suffix": "mA", + "decimals": 6, + "readonly": true + }, + { + "name": "Output Config", + "expanded": true, + "type": "group", + "children": [ + { + "name": "Control Method", + "type": "mutex", + "limits": [ + "Constant Current", + "Temperature PID" + ], + "value": "Constant Current", + "thermostat:set_param": { + "topic": "output", + "field": "pid" + }, + "children": [ + { + "name": "Set Current", + "type": "float", + "value": 0, + "step": 100, + "limits": [ + -2000, + 2000 + ], + "triggerOnShow": true, + "decimals": 6, + "suffix": "mA", + "thermostat:set_param": { + "topic": "output", + "field": "i_set" + }, + "lock": false + }, + { + "name": "Set Temperature", + "type": "float", + "value": 25, + "step": 0.1, + "limits": [ + -273, + 300 + ], + "format": "{value:.4f} °C", + "thermostat:set_param": { + "topic": "pid", + "field": "target" + }, + "lock": false + } + ] + }, + { + "name": "Limits", + "expanded": true, + "type": "group", + "children": [ + { + "name": "Max Cooling Current", + "type": "float", + "value": 0, + "step": 100, + "decimals": 6, + "limits": [ + 0, + 2000 + ], + "suffix": "mA", + "thermostat:set_param": { + "topic": "output", + "field": "max_i_pos" + }, + "lock": false + }, + { + "name": "Max Heating Current", + "type": "float", + "value": 0, + "step": 100, + "decimals": 6, + "limits": [ + 0, + 2000 + ], + "suffix": "mA", + "thermostat:set_param": { + "topic": "output", + "field": "max_i_neg" + }, + "lock": false + }, + { + "name": "Max Voltage Difference", + "type": "float", + "value": 0, + "step": 0.1, + "limits": [ + 0, + 5 + ], + "siPrefix": true, + "suffix": "V", + "thermostat:set_param": { + "topic": "output", + "field": "max_v" + }, + "lock": false + } + ] + } + ] + }, + { + "name": "Thermistor Config", + "expanded": true, + "type": "group", + "children": [ + { + "name": "T₀", + "type": "float", + "value": 25, + "step": 0.1, + "limits": [ + -100, + 100 + ], + "format": "{value:.4f} °C", + "thermostat:set_param": { + "topic": "s-h", + "field": "t0" + }, + "lock": false + }, + { + "name": "R₀", + "type": "float", + "value": 10000, + "step": 1, + "siPrefix": true, + "suffix": "Ω", + "thermostat:set_param": { + "topic": "s-h", + "field": "r0" + }, + "lock": false + }, + { + "name": "B", + "type": "float", + "value": 3950, + "step": 1, + "suffix": "K", + "decimals": 4, + "thermostat:set_param": { + "topic": "s-h", + "field": "b" + }, + "lock": false + }, + { + "name": "Postfilter Rate", + "type": "list", + "value": 16.67, + "thermostat:set_param": { + "topic": "postfilter", + "field": "rate" + }, + "limits": { + "Off": null, + "16.67 Hz": 16.67, + "20 Hz": 20.0, + "21.25 Hz": 21.25, + "27 Hz": 27.0 + }, + "lock": false + } + ] + }, + { + "name": "PID Config", + "expanded": true, + "type": "group", + "children": [ + { + "name": "Kp", + "type": "float", + "step": 0.1, + "suffix": "", + "thermostat:set_param": { + "topic": "pid", + "field": "kp" + }, + "lock": false + }, + { + "name": "Ki", + "type": "float", + "step": 0.1, + "suffix": "Hz", + "thermostat:set_param": { + "topic": "pid", + "field": "ki" + }, + "lock": false + }, + { + "name": "Kd", + "type": "float", + "step": 0.1, + "suffix": "s", + "thermostat:set_param": { + "topic": "pid", + "field": "kd" + }, + "lock": false + }, + { + "name": "PID Output Clamping", + "expanded": true, + "type": "group", + "children": [ + { + "name": "Minimum", + "type": "float", + "step": 100, + "limits": [ + -2000, + 2000 + ], + "decimals": 6, + "suffix": "mA", + "thermostat:set_param": { + "topic": "pid", + "field": "output_min" + }, + "lock": false + }, + { + "name": "Maximum", + "type": "float", + "step": 100, + "limits": [ + -2000, + 2000 + ], + "decimals": 6, + "suffix": "mA", + "thermostat:set_param": { + "topic": "pid", + "field": "output_max" + }, + "lock": false + } + ] + }, + { + "name": "PID Auto Tune", + "expanded": false, + "type": "group", + "children": [ + { + "name": "Target Temperature", + "type": "float", + "value": 20, + "step": 0.1, + "format": "{value:.4f} °C", + "pid_autotune": "target_temp" + }, + { + "name": "Test Current", + "type": "float", + "value": 0, + "decimals": 6, + "step": 100, + "limits": [ + -2000, + 2000 + ], + "suffix": "mA", + "pid_autotune": "test_current" + }, + { + "name": "Temperature Swing", + "type": "float", + "value": 1.5, + "step": 0.1, + "prefix": "±", + "format": "{value:.4f} °C", + "pid_autotune": "temp_swing" + }, + { + "name": "Lookback", + "type": "float", + "value": 3.0, + "step": 0.1, + "format": "{value:.4f} s", + "pid_autotune": "lookback" + }, + { + "name": "Run", + "type": "action", + "tip": "Run" + } + ] + } + ] + }, + { + "name": "Save to flash", + "type": "action", + "tip": "Save config to thermostat, applies on reset" + }, + { + "name": "Load from flash", + "type": "action", + "tip": "Load config from flash" + } + ] +} \ No newline at end of file diff --git a/pythermostat/pythermostat/thermostat_qt.py b/pythermostat/pythermostat/thermostat_qt.py index 116555f..cf08817 100755 --- a/pythermostat/pythermostat/thermostat_qt.py +++ b/pythermostat/pythermostat/thermostat_qt.py @@ -4,6 +4,7 @@ import asyncio import logging import argparse import importlib.resources +import json from PyQt6 import QtWidgets, QtGui, uic from PyQt6.QtCore import pyqtSlot import qasync @@ -12,6 +13,7 @@ from pythermostat.autotune import PIDAutotuneState from pythermostat.gui.model.thermostat import Thermostat, ThermostatConnectionState from pythermostat.gui.model.pid_autotuner import PIDAutoTuner from pythermostat.gui.view.connection_details_menu import ConnectionDetailsMenu +from pythermostat.gui.view.ctrl_panel import CtrlPanel from pythermostat.gui.view.info_box import InfoBox from pythermostat.gui.view.live_plot_view import LiveDataPlotter from pythermostat.gui.view.plot_options_menu import PlotOptionsMenu @@ -37,6 +39,12 @@ def get_argparser(): choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], help="Set the logging level", ) + parser.add_argument( + "-p", + "--param_tree", + default=importlib.resources.files("pythermostat.gui.view").joinpath("param_tree.json"), + help="Param Tree Description JSON File", + ) return parser @@ -44,7 +52,7 @@ def get_argparser(): class MainWindow(QtWidgets.QMainWindow): NUM_CHANNELS = 2 - def __init__(self): + def __init__(self, args): super().__init__() ui_file_path = importlib.resources.files("pythermostat.gui.view").joinpath("MainWindow.ui") @@ -80,6 +88,19 @@ class MainWindow(QtWidgets.QMainWindow): self._thermostat.connection_error.connect(handle_connection_error) + # Control Panel + def get_ctrl_panel_config(args): + with open(args.param_tree, "r", encoding="utf-8") as f: + return json.load(f)["ctrl_panel"] + + self._ctrl_panel_view = CtrlPanel( + self._thermostat, + self._autotuners, + self._info_box, + [self.ch0_tree, self.ch1_tree], + get_ctrl_panel_config(args), + ) + # Graphs self._channel_graphs = LiveDataPlotter( self._thermostat, @@ -212,7 +233,7 @@ async def coro_main(): ) ) - main_window = MainWindow() + main_window = MainWindow(args) main_window.show() if args.connect: