GUI: Control Panel changes #2

Open
atse wants to merge 21 commits from gui_dev-ctrl_panel into gui_dev
6 changed files with 733 additions and 450 deletions

View File

@ -28,7 +28,7 @@ class PIDAutoTuner(QObject):
def load_params_and_set_ready(self, ch): def load_params_and_set_ready(self, ch):
self.autotuners[ch].setParam( self.autotuners[ch].setParam(
self.target_temp[ch], self.target_temp[ch],
self.test_current[ch] / 1000, self.test_current[ch],
self.temp_swing[ch], self.temp_swing[ch],
1 / self.sampling_interval[ch], 1 / self.sampling_interval[ch],
self.lookback[ch], self.lookback[ch],

View File

@ -117,14 +117,14 @@ class Thermostat(QObject, metaclass=PropertyMeta):
async def save_cfg(self, ch): async def save_cfg(self, ch):
await self._client.save_config(ch) await self._client.save_config(ch)
self.info_box_trigger.emit( self.info_box_trigger.emit(
"Config saved", f"Channel {ch} Config has been saved from flash." "Settings saved", f"Channel {ch} Settings has been saved to flash."
) )
@asyncSlot() @asyncSlot()
async def load_cfg(self, ch): async def load_cfg(self, ch):
await self._client.load_config(ch) await self._client.load_config(ch)
self.info_box_trigger.emit( self.info_box_trigger.emit(
"Config loaded", f"Channel {ch} Config has been loaded from flash." "Settings loaded", f"Channel {ch} Settings has been loaded from flash."
) )
async def dfu(self): async def dfu(self):

View File

@ -4,42 +4,14 @@ from pyqtgraph.parametertree import (
Parameter, Parameter,
registerParameterType, registerParameterType,
) )
import pytec.gui.view.lockable_unit
class MutexParameter(pTypes.ListParameter): def set_tree_label_tips(tree):
""" for item in tree.listAllItems():
Mutually exclusive parameter where only one of its children is visible at a time, list selectable. p = item.param
if "tip" in p.opts:
The ordering of the list items determines which children will be visible. item.setToolTip(0, p.opts["tip"])
"""
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): class CtrlPanel(QObject):
@ -58,55 +30,56 @@ class CtrlPanel(QObject):
self.trees_ui = trees_ui self.trees_ui = trees_ui
self.NUM_CHANNELS = len(trees_ui) self.NUM_CHANNELS = len(trees_ui)
self.THERMOSTAT_PARAMETERS = [param_tree for i in range(self.NUM_CHANNELS)] def _set_value_with_lock(self, value):
if not self.opts.get("lock"):
self.setValue(value)
Parameter.set_value_with_lock = _set_value_with_lock
self.params = [ self.params = [
Parameter.create( Parameter.create(
name=f"Thermostat Channel {ch} Parameters", name=f"Thermostat Channel {ch} Parameters",
type="group", type="group",
value=ch, value=ch,
children=self.THERMOSTAT_PARAMETERS[ch], children=param_tree,
) )
for ch in range(self.NUM_CHANNELS) for ch in range(self.NUM_CHANNELS)
] ]
for i, param in enumerate(self.params): for ch, tree in enumerate(self.trees_ui):
param.channel = i
for i, tree in enumerate(self.trees_ui):
tree.setHeaderHidden(True) tree.setHeaderHidden(True)
tree.setParameters(self.params[i], showTop=False) tree.setParameters(self.params[ch], showTop=False)
self.params[i].setValue = self._setValue
self.params[i].sigTreeStateChanged.connect(sigTreeStateChanged_handle)
for handle in sigActivated_handles[i]: set_tree_label_tips(tree)
self.params[i].child(*handle[0]).sigActivated.connect(handle[1])
def _setValue(self, value, blockSignal=None): for ch, param in enumerate(self.params):
""" param.sigTreeStateChanged.connect(sigTreeStateChanged_handle)
Implement 'lock' mechanism for Parameter Type

Usability:
I think this is duplicated. "Set Current" should not reflect the value being set by "Thermostat". That area of ctrl panel is configured by user afterall. It is confusing to me if it suddenly becomes a "Reading" item. Also, the current readings already reflect the current being set already.

Instead, I think we should keep the "Set Current" and and "Setpoint" always shown and configurable. And there are some sorts of indicator indicating which value is being used for control.

image

Usability: I think this is duplicated. "Set Current" should not reflect the value being set by "Thermostat". That area of ctrl panel is configured by user afterall. It is confusing to me if it suddenly becomes a "Reading" item. Also, the current readings already reflect the current being set already. Instead, I think we should keep the "Set Current" and and "Setpoint" always shown and configurable. And there are some sorts of indicator indicating which value is being used for control. ![image](/attachments/1dba729b-7728-400b-94db-46c8a997d50e)
Modified from the source for handle in sigActivated_handles[ch]:
""" param.child(*handle[0]).sigActivated.connect(handle[1])
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(): def _indicate_usage(param, control_method="constant_current"):
if self.opts["lock"]: for item in param.child("i_set").items:
return value is_constant_current = control_method == "constant_current"
self.opts["value"] = value font = item.font(0)
self.sigValueChanged.emit( font.setUnderline(is_constant_current)
self, value font.setBold(is_constant_current)
) # value might change after signal is received by tree item item.setFont(0, font)
finally: for item in param.child("target").items:
if blockSignal is not None: is_temperature_pid = control_method == "temperature_pid"
self.sigValueChanged.connect(blockSignal) font = item.font(0)
font.setUnderline(is_temperature_pid)
font.setBold(is_temperature_pid)
item.setFont(0, font)
return self.opts["value"] param.child("output", "control_method").sigValueChanged.connect(
_indicate_usage
)
_indicate_usage(param.child("output", "control_method"))
for item in param.child("output", "control_method").items:
font = item.font(0)
font.setBold(True)
item.setFont(0, font)
def change_params_title(self, channel, path, title): def change_params_title(self, channel, path, title):
self.params[channel].child(*path).setOpts(title=title) self.params[channel].child(*path).setOpts(title=title)
@ -116,57 +89,59 @@ class CtrlPanel(QObject):
for settings in pid_settings: for settings in pid_settings:
channel = settings["channel"] channel = settings["channel"]
with QSignalBlocker(self.params[channel]): with QSignalBlocker(self.params[channel]):
self.params[channel].child("PID Config", "Kp").setValue( self.params[channel].child("pid", "kp").set_value_with_lock(
settings["parameters"]["kp"] settings["parameters"]["kp"]
) )
self.params[channel].child("PID Config", "Ki").setValue( self.params[channel].child("pid", "ki").set_value_with_lock(
settings["parameters"]["ki"] settings["parameters"]["ki"]
) )
self.params[channel].child("PID Config", "Kd").setValue( self.params[channel].child("pid", "kd").set_value_with_lock(
settings["parameters"]["kd"] settings["parameters"]["kd"]
) )
self.params[channel].child( self.params[channel].child(
"PID Config", "PID Output Clamping", "Minimum" "pid", "pid_output_clamping", "output_min"
).setValue(settings["parameters"]["output_min"] * 1000) ).set_value_with_lock(settings["parameters"]["output_min"])
self.params[channel].child( self.params[channel].child(
"PID Config", "PID Output Clamping", "Maximum" "pid", "pid_output_clamping", "output_max"
).setValue(settings["parameters"]["output_max"] * 1000) ).set_value_with_lock(settings["parameters"]["output_max"])
self.params[channel].child( self.params[channel].child(
"Output Config", "Control Method", "Set Temperature" "output", "control_method", "target"
).setValue(settings["target"]) ).set_value_with_lock(settings["target"])
@pyqtSlot("QVariantList") @pyqtSlot("QVariantList")
def update_report(self, report_data): def update_report(self, report_data):
for settings in report_data: for settings in report_data:
channel = settings["channel"] channel = settings["channel"]
with QSignalBlocker(self.params[channel]): with QSignalBlocker(self.params[channel]):
self.params[channel].child("Output Config", "Control Method").setValue( self.params[channel].child(
"Temperature PID" if settings["pid_engaged"] else "Constant Current" "output", "control_method"
).set_value_with_lock(
"temperature_pid" if settings["pid_engaged"] else "constant_current"
) )
self.params[channel].child( self.params[channel].child(
"Output Config", "Control Method", "Set Current" "output", "control_method", "i_set"
).setValue(settings["i_set"] * 1000) ).set_value_with_lock(settings["i_set"])
if settings["temperature"] is not None: if settings["temperature"] is not None:
self.params[channel].child("Temperature").setValue( self.params[channel].child(
settings["temperature"] "readings", "temperature"
) ).set_value_with_lock(settings["temperature"])
if settings["tec_i"] is not None: if settings["tec_i"] is not None:
self.params[channel].child("Current through TEC").setValue( self.params[channel].child(
settings["tec_i"] * 1000 "readings", "tec_i"
) ).set_value_with_lock(settings["tec_i"])
@pyqtSlot("QVariantList") @pyqtSlot("QVariantList")
def update_thermistor(self, sh_data): def update_thermistor(self, sh_data):
for sh_param in sh_data: for sh_param in sh_data:
channel = sh_param["channel"] channel = sh_param["channel"]
with QSignalBlocker(self.params[channel]): with QSignalBlocker(self.params[channel]):
self.params[channel].child("Thermistor Config", "T₀").setValue( self.params[channel].child("thermistor", "t0").set_value_with_lock(
sh_param["params"]["t0"] - 273.15 sh_param["params"]["t0"] - 273.15
) )
self.params[channel].child("Thermistor Config", "R₀").setValue( self.params[channel].child("thermistor", "r0").set_value_with_lock(
sh_param["params"]["r0"] sh_param["params"]["r0"]
) )
self.params[channel].child("Thermistor Config", "B").setValue( self.params[channel].child("thermistor", "b").set_value_with_lock(
sh_param["params"]["b"] sh_param["params"]["b"]
) )
@ -178,14 +153,14 @@ class CtrlPanel(QObject):
channel = pwm_params["channel"] channel = pwm_params["channel"]
with QSignalBlocker(self.params[channel]): with QSignalBlocker(self.params[channel]):
self.params[channel].child( self.params[channel].child(
"Output Config", "Limits", "Max Voltage Difference" "output", "limits", "max_v"
).setValue(pwm_params["max_v"]["value"]) ).set_value_with_lock(pwm_params["max_v"]["value"])
self.params[channel].child( self.params[channel].child(
"Output Config", "Limits", "Max Cooling Current" "output", "limits", "max_i_pos"
).setValue(pwm_params["max_i_pos"]["value"] * 1000) ).set_value_with_lock(pwm_params["max_i_pos"]["value"])
self.params[channel].child( self.params[channel].child(
"Output Config", "Limits", "Max Heating Current" "output", "limits", "max_i_neg"
).setValue(pwm_params["max_i_neg"]["value"] * 1000) ).set_value_with_lock(pwm_params["max_i_neg"]["value"])
for limit in "max_i_pos", "max_i_neg", "max_v": for limit in "max_i_pos", "max_i_neg", "max_v":
if pwm_params[limit]["value"] == 0.0: if pwm_params[limit]["value"] == 0.0:
@ -197,6 +172,6 @@ class CtrlPanel(QObject):
for postfilter_params in postfilter_data: for postfilter_params in postfilter_data:
channel = postfilter_params["channel"] channel = postfilter_params["channel"]
with QSignalBlocker(self.params[channel]): with QSignalBlocker(self.params[channel]):
self.params[channel].child( self.params[channel].child("postfilter", "rate").set_value_with_lock(
"Thermistor Config", "Postfilter Rate" postfilter_params["rate"]
).setValue(postfilter_params["rate"]) )

View File

@ -0,0 +1,185 @@
import re
from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtGui import QValidator
from pyqtgraph import SpinBox
import pyqtgraph.functions as fn
from pyqtgraph.parametertree import registerParameterItemType
from pyqtgraph.parametertree.parameterTypes import SimpleParameter, NumericParameterItem
# See https://github.com/pyqtgraph/pyqtgraph/issues/3115
fn.FLOAT_REGEX = re.compile(
r"(?P<number>[+-]?((((\d+(\.\d*)?)|(\d*\.\d+))([eE][+-]?\d+)?)|((?i:nan)|(inf))))\s*"
+ r"((?P<siPrefix>[u" + fn.SI_PREFIXES + r"]?)(?P<suffix>.*))?$"
)
class LockableUnitSpinBox(SpinBox):
"""
Extension of PyQtGraph's SpinBox widget.
Adds:
* The "pinSiPrefix" option, where the siPrefix could be fixed to a
particular scale instead of as determined by its value.
* The "noUnitEditing" option, where the suffix and pinned siPrefix
of the SpinBox text is fixed and uneditable.
"""
def __init__(self, parent=None, value=0.0, **kwargs):
super().__init__(parent, value, **kwargs)
self.lineEdit().cursorPositionChanged.connect(
self._editor_cursor_position_changed
)
def validate(self, strn, pos):
ret, strn, pos = super().validate(strn, pos)
if self.opts.get("noUnitEditing") is True:
suffix = self.opts["suffix"]
pinned_si_prefix = self.opts.get("pinSiPrefix")
suffix_edited = not strn.endswith(suffix)
pinned_si_prefix_edited = (
pinned_si_prefix is not None
and not strn.removesuffix(suffix).endswith(pinned_si_prefix)
)
if suffix_edited or pinned_si_prefix_edited:
ret = QValidator.State.Invalid
return ret, strn, pos
def _editor_cursor_position_changed(self, oldpos, newpos):
# Called on cursor position change
# Skips over the suffix and pinned SI prefix on cursor navigation if option
# noUnitEditing is enabled.
# Modified from the original Qt C++ source,
# QAbstractSpinBox::editorCursorPositionChanged.
# Their suffix is different than our suffix; there's no obvious way to set
# theirs here in the derived class since it is private.
if self.opts.get("noUnitEditing") is True:
edit = self.lineEdit()
if edit.hasSelectedText():
return # Allow for selecting units, for copy-and-paste
pinned_si_prefix = self.opts.get("pinSiPrefix") or ""
unit_len = len(pinned_si_prefix) + len(self.opts["suffix"])
text_len = len(edit.text())
pos = -1
# Cursor in unit
if text_len - unit_len < newpos < text_len:
if oldpos == text_len:
pos = text_len - unit_len
else:
pos = text_len
if pos != -1:
with QSignalBlocker(edit):
edit.setCursorPosition(pos)
def setOpts(self, **opts):
if "pinSiPrefix" in opts:
self.opts["pinSiPrefix"] = opts.pop("pinSiPrefix")
if "noUnitEditing" in opts:
self.opts["noUnitEditing"] = opts.pop("noUnitEditing")
super().setOpts(**opts)
def editingFinishedEvent(self):
# Modified from pyqtgraph.SpinBox.editingFinishedEvent source
new_text = self.lineEdit().text()
if new_text == self.lastText:
return
try:
val = self.interpret()
except Exception:
return
if val is False:
return
if val == self.val:
self.updateText() # still update text so that values are reformatted pretty-like
return
self.setValue(val, delaySignal=False) ## allow text update so that values are reformatted pretty-like
def formatText(self, prev=None):
"""
In addition to pyqtgraph.SpinBox's formatting, incorporate the
'pinSiPrefix' mechanism, where SI prefixes could be fixed.
"""
# Code modified from the PyQtGraph source
# get the number of decimal places to print
decimals = self.opts['decimals']
suffix = self.opts['suffix']
prefix = self.opts['prefix']
pin_si_prefix = self.opts.get("pinSiPrefix")
# format the string
val = self.value()
if self.opts['siPrefix'] is True:
# SI prefix was requested, so scale the value accordingly
if pin_si_prefix is not None and pin_si_prefix in fn.SI_PREFIX_EXPONENTS:
# fixed scale
s = 10**-fn.SI_PREFIX_EXPONENTS[pin_si_prefix]
p = pin_si_prefix
elif self.val == 0 and prev is not None:
# special case: if it's zero use the previous prefix
(s, p) = fn.siScale(prev)
else:
(s, p) = fn.siScale(val)
parts = {'value': val, 'suffix': suffix, 'decimals': decimals, 'siPrefix': p, 'scaledValue': s*val, 'prefix':prefix}
else:
# no SI prefix /suffix requested; scale is 1
parts = {'value': val, 'suffix': suffix, 'decimals': decimals, 'siPrefix': '', 'scaledValue': val, 'prefix':prefix}
parts['prefixGap'] = '' if parts['prefix'] == '' else ' '
parts['suffixGap'] = '' if (parts['suffix'] == '' and parts['siPrefix'] == '') else ' '
return self.opts['format'].format(**parts)
class UnitfulNumericParameterItem(NumericParameterItem):
"""
Subclasses PyQtGraph's `NumericParameterItem` and uses
UnitfulSpinBox for editing.
"""
def makeWidget(self):
opts = self.param.opts
t = opts['type']
defs = {
'value': 0, 'min': None, 'max': None,
'step': 1.0, 'dec': False,
'siPrefix': False, 'suffix': '', 'decimals': 3,
'pinSiPrefix': None, 'noUnitEditing': False,
}
if t == 'int':
defs['int'] = True
defs['minStep'] = 1.0
for k in defs:
if k in opts:
defs[k] = opts[k]
if 'limits' in opts:
defs['min'], defs['max'] = opts['limits']
w = LockableUnitSpinBox()
w.setOpts(**defs)
w.sigChanged = w.sigValueChanged
w.sigChanging = w.sigValueChanging
return w
registerParameterItemType(
"float", UnitfulNumericParameterItem, SimpleParameter, override=True
)
registerParameterItemType(
"int", UnitfulNumericParameterItem, SimpleParameter, override=True
)

View File

@ -1,335 +1,459 @@
{ {
"ctrl_panel":[ "ctrl_panel": [
{ {
"name":"Temperature", "name": "readings",
"type":"float", "title": "Readings",
"format":"{value:.4f} °C", "type": "group",
"readonly":true "tip": "Thermostat readings",
}, "children": [
{ {
"name":"Current through TEC", "name": "temperature",
"type":"float", "title": "Temperature",
"suffix":"mA", "type": "float",
"decimals":6, "format": "{value:.4f} {suffix}",
"readonly":true "suffix": "°C",
}, "readonly": true,
{ "tip": "The measured temperature at the thermistor"
"name":"Output Config", },
"expanded":true, {
"type":"group", "name": "tec_i",
"children":[ "title": "Current through TEC",
{ "type": "float",
"name":"Control Method", "siPrefix": true,
"type":"mutex", "suffix": "A",
"limits":[ "decimals": 6,
"Constant Current", "readonly": true,
"Temperature PID" "tip": "The measured current through the TEC"
], }
"thermostat:set_param":{ ]
"topic":"pwm", },
"field":"pid" {
}, "name": "output",
"children":[ "title": "Output Settings",
{ "expanded": true,
"name":"Set Current", "type": "group",
"type":"float", "tip": "Settings of the output to the TEC",
"value":0, "children": [
"step":100, {
"limits":[ "name": "control_method",
-2000, "title": "Control Method",
2000 "type": "list",
], "limits": {
"triggerOnShow":true, "Constant Current": "constant_current",
"decimals":6, "Temperature PID": "temperature_pid"
"suffix":"mA", },
"thermostat:set_param":{ "thermostat:set_param": {
"topic":"pwm", "topic": "pwm",
"field":"i_set" "field": "pid"
}, },
"lock":false "tip": "Select control method of output",
}, "children": [
{ {
"name":"Set Temperature", "name": "i_set",
"type":"float", "title": "Set Current",
"value":25, "type": "float",
"step":0.1, "value": 0,
"limits":[ "step": 0.1,
-273, "limits": [
300 -2,
], 2
"format":"{value:.4f} °C", ],
"thermostat:set_param":{ "decimals": 6,
"topic":"pid", "pinSiPrefix": "m",
"field":"target" "suffix": "A",
}, "siPrefix": true,
"lock":false "noUnitEditing": true,
} "compactHeight": false,
] "thermostat:set_param": {
}, "topic": "pwm",
{ "field": "i_set"
"name":"Limits", },
"expanded":true, "tip": "The set current through TEC",
"type":"group", "lock": false
"children":[
{
"name":"Max Cooling Current",
"type":"float",
"value":0,
"step":100,
"decimals":6,
"limits":[
0,
2000
],
"suffix":"mA",
"thermostat:set_param":{
"topic":"pwm",
"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":"pwm",
"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":"pwm",
"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", "name": "target",
"type":"action", "title": "Setpoint",
"tip":"Run" "type": "float",
} "value": 25,
] "step": 0.1,
} "limits": [
] -273,
}, 300
{ ],
"name":"Save to flash", "format": "{value:.4f} {suffix}",
"type":"action", "suffix": "°C",
"tip":"Save config to thermostat, applies on reset" "noUnitEditing": true,
}, "compactHeight": false,
{ "thermostat:set_param": {
"name":"Load from flash", "topic": "pid",
"type":"action", "field": "target"
"tip":"Load config from flash" },
} "tip": "The temperature setpoint of the TEC",

Replacing the incorrectly defined FLOAT_REGEX on pyqtgraph.functions is more preferable.

Replacing the incorrectly defined FLOAT_REGEX on pyqtgraph.functions is more preferable.
Outdated
Review

How do I do this as a patch to PyQtGraph itself? Seems to be a cleaner solution compared to patching it in our own code.

How do I do this as a patch to PyQtGraph itself? Seems to be a cleaner solution compared to patching it in our own code.
] "lock": false
} }
]
},
{
"name": "limits",
"title": "Limits",
"expanded": true,
"type": "group",
"tip": "The limits of output, with the polarity at the front panel as reference",
"children": [
{
"name": "max_i_pos",
"title": "Max Cooling Current",
"type": "float",
"value": 0,
"step": 0.1,
"decimals": 6,
"limits": [
0,
2
],
"siPrefix": true,
"pinSiPrefix": "m",
"suffix": "A",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pwm",
"field": "max_i_pos"
},
"tip": "The maximum cooling (+ve) current through the output pins",
"lock": false
},
{
"name": "max_i_neg",
"title": "Max Heating Current",
"type": "float",
"value": 0,
"step": 0.1,
"decimals": 6,
"siPrefix": true,
"pinSiPrefix": "m",
"suffix": "A",
"noUnitEditing": true,
"limits": [
0,
2
],
"compactHeight": false,
"thermostat:set_param": {
"topic": "pwm",
"field": "max_i_neg"
},
"tip": "The maximum heating (-ve) current through the output pins",
"lock": false
},
{
"name": "max_v",
"title": "Max Absolute Voltage",
"type": "float",
"value": 0,
"step": 0.1,
"decimals": 3,
"limits": [
0,
4
],
"suffix": "V",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pwm",
"field": "max_v"
},
"tip": "The maximum voltage (in both directions) across the output pins",
"lock": false
}
]
}
]
},
{
"name": "thermistor",
"title": "Thermistor Settings",
"expanded": true,
"type": "group",
"tip": "Parameters for the resistance to temperature conversion with the B-Parameter equation",
"children": [
{
"name": "t0",
"title": "T₀",
"type": "float",
"value": 25,
"step": 0.1,
"limits": [
-100,
100
],
"format": "{value:.4f} {suffix}",
"suffix": "°C",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "s-h",
"field": "t0"
},
"tip": "The base temperature",
"lock": false
},
{
"name": "r0",
"title": "R₀",
"type": "float",
"value": 10000,
"step": 100,
"min": 0,
"siPrefix": true,
"pinSiPrefix": "k",
"suffix": "Ω",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "s-h",
"field": "r0"
},
"tip": "The resistance of the thermistor at base temperature T₀",
"lock": false
},
{
"name": "b",
"title": "B",

You should fix all the siPrefix. Especially when we do not allow unit to be modified now. It will be confusing if the siPrefix of the unit suddenly changes.

You should fix **all** the siPrefix. Especially when we do not allow unit to be modified now. It will be confusing if the siPrefix of the unit suddenly changes.
"type": "float",
"value": 3950,
"step": 10,
"suffix": "K",
"noUnitEditing": true,
"decimals": 4,
"compactHeight": false,
"thermostat:set_param": {
"topic": "s-h",
"field": "b"
},
"tip": "The Beta Parameter",
"lock": false
}
]
},
{
"name": "postfilter",
"title": "ADC Settings",
"type": "group",
"tip": "Settings of the ADC on the SENS input",
"children": [
{
"name": "rate",
"title": "50/60 Hz Rejection Filter",
"type": "list",
"value": 16.67,
"thermostat:set_param": {
"topic": "postfilter",
"field": "rate"
},
"limits": {
"16.67 SPS": 16.67,
"20 SPS": 20.0,
"21.25 SPS": 21.25,
"27 SPS": 27.0,
"Off": null
},
"tip": "Adjust the output data rate of the enhanced 50 Hz & 60 Hz rejection filter\n(Helps avoid mains interference)",
"lock": false
}
]
},
{
"name": "pid",
"title": "PID Settings",
"expanded": true,
"type": "group",
"tip": "Settings of PID parameters and clamping",
"children": [
{
"name": "kp",
"title": "Kp",
"type": "float",
"step": 0.1,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "kp"
},
"tip": "Proportional gain",
"lock": false
},
{
"name": "ki",
"title": "Ki",
"type": "float",
"step": 0.1,
"suffix": "Hz",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "ki"
},
"tip": "Integral gain",
"lock": false
},
{
"name": "kd",
"title": "Kd",
"type": "float",
"step": 0.1,
"suffix": "s",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "kd"
},
"tip": "Differential gain",
"lock": false
},
{
"name": "pid_output_clamping",
"title": "PID Output Clamping",
"expanded": true,
"type": "group",
"tip": "Clamps PID outputs to specified range\nCould be different than output limits",
"children": [
{
"name": "output_min",
"title": "Minimum",
"type": "float",
"step": 0.1,
"limits": [
-2,
2
],
"decimals": 6,
"siPrefix": true,
"pinSiPrefix": "m",
"suffix": "A",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "output_min"
},
"tip": "Minimum PID output",
"lock": false
},
{
"name": "output_max",
"title": "Maximum",
"type": "float",
"step": 0.1,
"limits": [
-2,
2
],
"decimals": 6,
"siPrefix": true,
"pinSiPrefix": "m",
"suffix": "A",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
"field": "output_max"
},
"tip": "Maximum PID output",
"lock": false
}
]
},
{
"name": "pid_autotune",
"title": "PID Autotune",
"expanded": false,
"type": "group",
"tip": "Automatically tune PID parameters",
"children": [
{
"name": "target_temp",
"title": "Target Temperature",
"type": "float",
"value": 20,
"step": 0.1,
"format": "{value:.4f} {suffix}",
"suffix": "°C",
"noUnitEditing": true,
"compactHeight": false,
"pid_autotune": "target_temp",
"tip": "The target temperature to autotune for"
},
{
"name": "test_current",
"title": "Test Current",
"type": "float",
"value": 0,
"decimals": 6,
"step": 0.1,
"limits": [
0,
2
],
"siPrefix": true,
"pinSiPrefix": "m",
"suffix": "A",
"noUnitEditing": true,
"compactHeight": false,
"pid_autotune": "test_current",
"tip": "The testing current when autotuning"
},
{
"name": "temp_swing",
"title": "Temperature Swing",
"type": "float",
"value": 1.5,
"step": 0.1,
"format": "{value:.4f} {suffix}",
"suffix": "K",
"noUnitEditing": true,
"compactHeight": false,
"pid_autotune": "temp_swing",
"tip": "The temperature swing around the target"
},
{
"name": "lookback",
"title": "Lookback",
"type": "float",
"value": 3.0,
"step": 0.1,
"format": "{value:.4f} {suffix}",
"noUnitEditing": true,
"suffix": "s",
"compactHeight": false,
"pid_autotune": "lookback",
"tip": "Amount of time referenced for tuning"
},
{
"name": "run_pid",
"title": "Run",
"type": "action",
"tip": "Run PID Autotune with above settings"
}
]
}
]
},
{
"name": "save",
"title": "Save to flash",
"type": "action",
"tip": "Save settings to thermostat, applies on reset"
},
{
"name": "load",
"title": "Load from flash",
"type": "action",
"tip": "Load settings from thermostat"
}
]
}

View File

@ -78,11 +78,11 @@ class MainWindow(QtWidgets.QMainWindow):
param_tree_sigActivated_handles = [ param_tree_sigActivated_handles = [
[ [
[["Save to flash"], partial(self.thermostat.save_cfg, ch)], [["save"], partial(self.thermostat.save_cfg, ch)],
[["Load from flash"], partial(self.thermostat.load_cfg, ch)], [["load"], partial(self.thermostat.load_cfg, ch)],
[ [
["PID Config", "PID Auto Tune", "Run"], ["pid", "pid_autotune", "run_pid"],
partial(self.pid_auto_tune_request, ch), partial(self.pid_autotune_request, ch),
], ],
] ]
for ch in range(self.NUM_CHANNELS) for ch in range(self.NUM_CHANNELS)
@ -262,26 +262,25 @@ class MainWindow(QtWidgets.QMainWindow):
@asyncSlot(object, object) @asyncSlot(object, object)
async def send_command(self, param, changes): async def send_command(self, param, changes):
"""Translates parameter tree changes into thermostat set_param calls""" """Translates parameter tree changes into thermostat set_param calls"""
ch = param.channel ch = param.value()
for inner_param, change, data in changes: for inner_param, change, data in changes:
if change == "value": if change == "value":
new_value = data new_value = data
if "thermostat:set_param" in inner_param.opts: if "thermostat:set_param" in inner_param.opts:
atse marked this conversation as resolved Outdated

Looks like a hack. The unit conversion should not be affected by the title.

Looks like a hack. The unit conversion should not be affected by the title.
Outdated
Review

Removed the need for it in latest force-push with the pinSiPrefix option of UnitfulSpinBox.

Removed the need for it in latest force-push with the `pinSiPrefix` option of `UnitfulSpinBox`.
if inner_param.opts.get("suffix", None) == "mA":
new_value /= 1000 # Given in mA
thermostat_param = inner_param.opts["thermostat:set_param"] thermostat_param = inner_param.opts["thermostat:set_param"]
# Handle thermostat command irregularities # Handle thermostat command irregularities
match inner_param.name(), new_value: match inner_param.name(), new_value:
case "Postfilter Rate", None: case "rate", None:
thermostat_param = thermostat_param.copy() thermostat_param = thermostat_param.copy()
thermostat_param["field"] = "off" thermostat_param["field"] = "off"
new_value = "" new_value = ""
case "Control Method", "Constant Current": case "control_method", "constant_current":
return thermostat_param = thermostat_param.copy()
case "Control Method", "Temperature PID": thermostat_param["field"] = "i_set"
new_value = inner_param.child("i_set").value()
case "control_method", "temperature_pid":
new_value = "" new_value = ""
inner_param.setOpts(lock=True) inner_param.setOpts(lock=True)
@ -295,7 +294,7 @@ class MainWindow(QtWidgets.QMainWindow):
self.autotuners.set_params(auto_tuner_param, ch, new_value) self.autotuners.set_params(auto_tuner_param, ch, new_value)
@asyncSlot() @asyncSlot()
async def pid_auto_tune_request(self, ch=0): async def pid_autotune_request(self, ch=0):
match self.autotuners.get_state(ch): match self.autotuners.get_state(ch):
case PIDAutotuneState.STATE_OFF | PIDAutotuneState.STATE_FAILED: case PIDAutotuneState.STATE_OFF | PIDAutotuneState.STATE_FAILED:
self.autotuners.load_params_and_set_ready(ch) self.autotuners.load_params_and_set_ready(ch)
@ -312,18 +311,18 @@ class MainWindow(QtWidgets.QMainWindow):
match self.autotuners.get_state(ch): match self.autotuners.get_state(ch):
case PIDAutotuneState.STATE_OFF: case PIDAutotuneState.STATE_OFF:
self.ctrl_panel_view.change_params_title( self.ctrl_panel_view.change_params_title(
ch, ("PID Config", "PID Auto Tune", "Run"), "Run" ch, ("pid", "pid_autotune", "run_pid"), "Run"
) )
case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN: case PIDAutotuneState.STATE_READY | PIDAutotuneState.STATE_RELAY_STEP_UP | PIDAutotuneState.STATE_RELAY_STEP_DOWN:
self.ctrl_panel_view.change_params_title( self.ctrl_panel_view.change_params_title(
ch, ("PID Config", "PID Auto Tune", "Run"), "Stop" ch, ("pid", "pid_autotune", "run_pid"), "Stop"
) )
ch_tuning.append(ch) ch_tuning.append(ch)
case PIDAutotuneState.STATE_SUCCEEDED: case PIDAutotuneState.STATE_SUCCEEDED:
self.info_box.display_info_box( self.info_box.display_info_box(
"PID Autotune Success", "PID Autotune Success",
f"Channel {ch} PID Config has been loaded to Thermostat. Regulating temperature.", f"Channel {ch} PID Settings has been loaded to Thermostat. Regulating temperature.",
) )
self.info_box.show() self.info_box.show()