Compare commits

..

21 Commits

Author SHA1 Message Date
9c22950e59 State 2024-08-21 16:18:01 +08:00
cebf995427 Remove all timeouts from aioclient 2024-08-21 16:18:01 +08:00
50c1bb5929 Connecting task moved? 2024-08-21 16:18:01 +08:00
6b08720f4f AsyncIO version Client -> AsyncioClient 2024-08-21 16:18:01 +08:00
b76f5a01fa Exclusively use the Thermostat object as a medium
All calls to the Thermostat should be forwarded by the medium.
2024-08-21 16:18:01 +08:00
4aaadff2ab Integrate WrappedClient into Thermostat model 2024-08-21 12:28:36 +08:00
1ab5c9e7ee Should not stop cancelling read if timeout'd 2024-08-21 12:28:36 +08:00
7019bbe004 Fix Autotuner state for forceful disconnect 2024-08-21 12:28:36 +08:00
1b64a88c75 Correct exception catching
asyncio.Task.result() is simply going to throw the exception in
asyncio.Task.exception(), there is no need to manually throw it.
2024-08-21 12:28:31 +08:00
fae0c4141d Make connection loss handling more elegant
Show an info box on connection lost informing the user that the
Thermostat was forcefully disconnected.
2024-08-21 12:28:05 +08:00
f387a1e085 ================Force connection fix starts here======== 2024-08-21 12:28:05 +08:00
9bf0a8bc81 Just catch asyncio.TimeoutError
Will just change to TimeoutError once we switch to Python 3.11 in the
flake.
2024-08-21 12:28:05 +08:00
cedb828959 Remove exception too general 2024-08-21 12:28:02 +08:00
aaaef450f3 Formatting 2024-08-21 11:16:15 +08:00
9bdea9d873 flake.nix: nixfmt-rfc-style 2024-08-21 11:16:15 +08:00
30fdcfa879 Use asserts to check for connectivity 2024-08-21 11:16:15 +08:00
e2ea417737 Add back the parent 2024-08-21 11:16:15 +08:00
c3b59c2924 README: Proofread 2024-08-21 11:16:15 +08:00
728a3fb7bd Swap order arounda bit more 2024-08-21 11:16:15 +08:00
905383428b Fix method call 2024-08-21 11:16:15 +08:00
870d10b525 Use qtextras 2024-08-21 11:16:15 +08:00
6 changed files with 488 additions and 773 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], self.test_current[ch] / 1000,
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

@ -3,9 +3,16 @@ from qasync import asyncSlot
from pytec.gui.model.property import Property, PropertyMeta from pytec.gui.model.property import Property, PropertyMeta
import asyncio import asyncio
import logging import logging
from enum import Enum
from pytec.aioclient import AsyncioClient from pytec.aioclient import AsyncioClient
class ThermostatConnectionState(Enum):
STATE_DISCONNECTED = "disconnected"
STATE_CONNECTING = "connecting"
STATE_CONNECTED = "connected"
class Thermostat(QObject, metaclass=PropertyMeta): class Thermostat(QObject, metaclass=PropertyMeta):
hw_rev = Property(dict) hw_rev = Property(dict)
fan = Property(dict) fan = Property(dict)
@ -109,14 +116,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(
"Settings saved", f"Channel {ch} Settings has been saved to flash." "Config saved", f"Channel {ch} Config has been saved from 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(
"Settings loaded", f"Channel {ch} Settings has been loaded from flash." "Config loaded", f"Channel {ch} Config has been loaded from flash."
) )
async def dfu(self): async def dfu(self):

View File

@ -4,14 +4,42 @@ from pyqtgraph.parametertree import (
Parameter, Parameter,
registerParameterType, registerParameterType,
) )
import pytec.gui.view.lockable_unit
def set_tree_label_tips(tree): class MutexParameter(pTypes.ListParameter):
for item in tree.listAllItems(): """
p = item.param Mutually exclusive parameter where only one of its children is visible at a time, list selectable.
if "tip" in p.opts:
item.setToolTip(0, p.opts["tip"]) 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): class CtrlPanel(QObject):
@ -30,63 +58,55 @@ 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)
def _set_value_with_lock(self, value): self.THERMOSTAT_PARAMETERS = [param_tree for i in range(self.NUM_CHANNELS)]
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=param_tree, children=self.THERMOSTAT_PARAMETERS[ch],
) )
for ch in range(self.NUM_CHANNELS) for ch in range(self.NUM_CHANNELS)
] ]
for ch, tree in enumerate(self.trees_ui): for i, param in enumerate(self.params):
param.channel = i
for i, tree in enumerate(self.trees_ui):
tree.setHeaderHidden(True) tree.setHeaderHidden(True)
tree.setParameters(self.params[ch], showTop=False) tree.setParameters(self.params[i], showTop=False)
self.params[i].setValue = self._setValue
self.params[i].sigTreeStateChanged.connect(sigTreeStateChanged_handle)
set_tree_label_tips(tree) for handle in sigActivated_handles[i]:
self.params[i].child(*handle[0]).sigActivated.connect(handle[1])
for ch, param in enumerate(self.params): def _setValue(self, value, blockSignal=None):
param.sigTreeStateChanged.connect(sigTreeStateChanged_handle) """
Implement 'lock' mechanism for Parameter Type
for handle in sigActivated_handles[ch]: Modified from the source
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
self.params[ch].child("output", "control_method").sigValueChanged.connect( if "lock" in self.opts.keys():
lambda param, value: param.parent() if self.opts["lock"]:
.parent() return value
.child("pid") self.opts["value"] = value
.setOpts(expanded=(value == "temperature_pid")) 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)
def _indicate_usage(param, control_method="constant_current"): return self.opts["value"]
for item in param.child("i_set").items:
is_constant_current = control_method == "constant_current"
font = item.font(0)
font.setUnderline(is_constant_current)
font.setBold(is_constant_current)
item.setFont(0, font)
for item in param.child("target").items:
is_temperature_pid = control_method == "temperature_pid"
font = item.font(0)
font.setUnderline(is_temperature_pid)
font.setBold(is_temperature_pid)
item.setFont(0, font)
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)
@ -96,59 +116,57 @@ 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", "kp").set_value_with_lock( self.params[channel].child("PID Config", "Kp").setValue(
settings["parameters"]["kp"] settings["parameters"]["kp"]
) )
self.params[channel].child("pid", "ki").set_value_with_lock( self.params[channel].child("PID Config", "Ki").setValue(
settings["parameters"]["ki"] settings["parameters"]["ki"]
) )
self.params[channel].child("pid", "kd").set_value_with_lock( self.params[channel].child("PID Config", "Kd").setValue(
settings["parameters"]["kd"] settings["parameters"]["kd"]
) )
self.params[channel].child( self.params[channel].child(
"pid", "pid_output_clamping", "output_min" "PID Config", "PID Output Clamping", "Minimum"
).set_value_with_lock(settings["parameters"]["output_min"]) ).setValue(settings["parameters"]["output_min"] * 1000)
self.params[channel].child( self.params[channel].child(
"pid", "pid_output_clamping", "output_max" "PID Config", "PID Output Clamping", "Maximum"
).set_value_with_lock(settings["parameters"]["output_max"]) ).setValue(settings["parameters"]["output_max"] * 1000)
self.params[channel].child( self.params[channel].child(
"output", "control_method", "target" "Output Config", "Control Method", "Set Temperature"
).set_value_with_lock(settings["target"]) ).setValue(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( self.params[channel].child("Output Config", "Control Method").setValue(
"output", "control_method" "Temperature PID" if settings["pid_engaged"] else "Constant Current"
).set_value_with_lock(
"temperature_pid" if settings["pid_engaged"] else "constant_current"
) )
self.params[channel].child( self.params[channel].child(
"output", "control_method", "i_set" "Output Config", "Control Method", "Set Current"
).set_value_with_lock(settings["i_set"]) ).setValue(settings["i_set"] * 1000)
if settings["temperature"] is not None: if settings["temperature"] is not None:
self.params[channel].child( self.params[channel].child("Temperature").setValue(
"readings", "temperature" settings["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( self.params[channel].child("Current through TEC").setValue(
"readings", "tec_i" settings["tec_i"] * 1000
).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", "t0").set_value_with_lock( self.params[channel].child("Thermistor Config", "T₀").setValue(
sh_param["params"]["t0"] - 273.15 sh_param["params"]["t0"] - 273.15
) )
self.params[channel].child("thermistor", "r0").set_value_with_lock( self.params[channel].child("Thermistor Config", "R₀").setValue(
sh_param["params"]["r0"] sh_param["params"]["r0"]
) )
self.params[channel].child("thermistor", "b").set_value_with_lock( self.params[channel].child("Thermistor Config", "B").setValue(
sh_param["params"]["b"] sh_param["params"]["b"]
) )
@ -160,14 +178,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", "limits", "max_v" "Output Config", "Limits", "Max Voltage Difference"
).set_value_with_lock(pwm_params["max_v"]["value"]) ).setValue(pwm_params["max_v"]["value"])
self.params[channel].child( self.params[channel].child(
"output", "limits", "max_i_pos" "Output Config", "Limits", "Max Cooling Current"
).set_value_with_lock(pwm_params["max_i_pos"]["value"]) ).setValue(pwm_params["max_i_pos"]["value"] * 1000)
self.params[channel].child( self.params[channel].child(
"output", "limits", "max_i_neg" "Output Config", "Limits", "Max Heating Current"
).set_value_with_lock(pwm_params["max_i_neg"]["value"]) ).setValue(pwm_params["max_i_neg"]["value"] * 1000)
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:
@ -179,6 +197,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("postfilter", "rate").set_value_with_lock( self.params[channel].child(
postfilter_params["rate"] "Thermistor Config", "Postfilter Rate"
) ).setValue(postfilter_params["rate"])

View File

@ -1,185 +0,0 @@
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,489 +1,365 @@
{ {
"ctrl_panel": [ "ctrl_panel":[
{ {
"name": "readings", "name":"Temperature",
"title": "Readings", "type":"float",
"type": "group", "format":"{value:.4f} °C",
"tip": "Thermostat readings", "readonly":true
"children": [ },
{ {
"name": "temperature", "name":"Current through TEC",
"title": "Temperature", "type":"float",
"type": "float", "suffix":"mA",
"format": "{value:.4f} {suffix}", "decimals":6,
"suffix": "°C", "readonly":true
"readonly": true, },
"tip": "The measured temperature at the thermistor" {
}, "name":"Output Config",
{ "expanded":true,
"name": "tec_i", "type":"group",
"title": "Current through TEC", "children":[
"type": "float", {
"siPrefix": true, "name":"Control Method",
"suffix": "A", "type":"mutex",
"decimals": 6, "limits":[
"readonly": true, "Constant Current",
"tip": "The measured current through the TEC" "Temperature PID"
} ],
] "activaters":[
}, null,
{ [
"name": "output", "pwm",
"title": "Output Settings", "ch",
"expanded": true, "pid"
"type": "group", ]
"tip": "Settings of the output to the TEC", ],
"children": [ "children":[
{ {
"name": "control_method", "name":"Set Current",
"title": "Control Method", "type":"float",
"type": "list", "value":0,
"limits": { "step":100,
"Constant Current": "constant_current", "limits":[
"Temperature PID": "temperature_pid" -2000,
}, 2000
"activaters": [ ],
null, "triggerOnShow":true,
[ "decimals":6,
"pwm", "suffix":"mA",
"ch", "param":[
"pid" "pwm",
] "ch",
], "i_set"
"tip": "Select control method of output", ],
"children": [ "lock":false
{ },
"name": "i_set", {
"title": "Set Current", "name":"Set Temperature",
"type": "float", "type":"float",
"value": 0, "value":25,
"step": 0.1, "step":0.1,
"limits": [ "limits":[
-2, -273,
2 300
], ],
"decimals": 6, "format":"{value:.4f} °C",
"pinSiPrefix": "m", "param":[
"suffix": "A", "pid",
"siPrefix": true, "ch",
"noUnitEditing": true, "target"
"compactHeight": false, ],
"param": [ "lock":false
"pwm", }
"ch", ]
"i_set" },
], {
"tip": "The set current through TEC", "name":"Limits",
"lock": false "expanded":true,
}, "type":"group",
{ "children":[
"name": "target", {
"title": "Setpoint", "name":"Max Cooling Current",
"type": "float", "type":"float",
"value": 25, "value":0,
"step": 0.1, "step":100,
"limits": [ "decimals":6,
-273, "limits":[
300 0,
], 2000
"format": "{value:.4f} {suffix}", ],
"suffix": "°C", "suffix":"mA",
"noUnitEditing": true, "param":[
"compactHeight": false, "pwm",
"param": [ "ch",
"pid", "max_i_pos"
"ch", ],
"target" "lock":false
], },
"tip": "The temperature setpoint of the TEC", {
"lock": false "name":"Max Heating Current",
} "type":"float",
] "value":0,
}, "step":100,
{ "decimals":6,
"name": "limits", "limits":[
"title": "Limits", 0,
"expanded": true, 2000
"type": "group", ],
"tip": "The limits of output, with the polarity at the front panel as reference", "suffix":"mA",
"children": [ "param":[
{ "pwm",
"name": "max_i_pos", "ch",
"title": "Max Cooling Current", "max_i_neg"
"type": "float", ],
"value": 0, "lock":false
"step": 0.1, },
"decimals": 6, {
"limits": [ "name":"Max Voltage Difference",
0, "type":"float",
2 "value":0,
], "step":0.1,
"siPrefix": true, "limits":[
"pinSiPrefix": "m", 0,
"suffix": "A", 5
"noUnitEditing": true, ],
"compactHeight": false, "siPrefix":true,
"param": [ "suffix":"V",
"pwm", "param":[
"ch", "pwm",
"max_i_pos" "ch",
], "max_v"
"tip": "The maximum cooling (+ve) current through the output pins", ],
"lock": false "lock":false
}, }
{ ]
"name": "max_i_neg", }
"title": "Max Heating Current", ]
"type": "float", },
"value": 0, {
"step": 0.1, "name":"Thermistor Config",
"decimals": 6, "expanded":true,
"siPrefix": true, "type":"group",
"pinSiPrefix": "m", "children":[
"suffix": "A", {
"noUnitEditing": true, "name":"T₀",
"limits": [ "type":"float",
0, "value":25,
2 "step":0.1,
], "limits":[
"compactHeight": false, -100,
"param": [ 100
"pwm", ],
"ch", "format":"{value:.4f} °C",
"max_i_neg" "param":[
], "s-h",
"tip": "The maximum heating (-ve) current through the output pins", "ch",
"lock": false "t0"
}, ],
{ "lock":false
"name": "max_v", },
"title": "Max Absolute Voltage", {
"type": "float", "name":"R₀",
"value": 0, "type":"float",
"step": 0.1, "value":10000,
"decimals": 3, "step":1,
"limits": [ "siPrefix":true,
0, "suffix":"Ω",
4 "param":[
], "s-h",
"suffix": "V", "ch",
"noUnitEditing": true, "r0"
"compactHeight": false, ],
"param": [ "lock":false
"pwm", },
"ch", {
"max_v" "name":"B",
], "type":"float",
"tip": "The maximum voltage (in both directions) across the output pins", "value":3950,
"lock": false "step":1,
} "suffix":"K",
] "decimals":4,
} "param":[
] "s-h",
}, "ch",
{ "b"
"name": "thermistor", ],
"title": "Thermistor Settings", "lock":false
"expanded": true, },
"type": "group", {
"tip": "Parameters for the resistance to temperature conversion with the B-Parameter equation", "name":"Postfilter Rate",
"children": [ "type":"list",
{ "value":16.67,
"name": "t0", "param":[
"title": "T₀", "postfilter",
"type": "float", "ch",
"value": 25, "rate"
"step": 0.1, ],
"limits": [ "limits":{
-100, "Off":null,
100 "16.67 Hz":16.67,
], "20 Hz":20.0,
"format": "{value:.4f} {suffix}", "21.25 Hz":21.25,
"suffix": "°C", "27 Hz":27.0
"noUnitEditing": true, },
"compactHeight": false, "lock":false
"param": [ }
"s-h", ]
"ch", },
"t0" {
], "name":"PID Config",
"tip": "The base temperature", "expanded":true,
"lock": false "type":"group",
}, "children":[
{ {
"name": "r0", "name":"Kp",
"title": "R₀", "type":"float",
"type": "float", "step":0.1,
"value": 10000, "suffix":"",
"step": 100, "param":[
"min": 0, "pid",
"siPrefix": true, "ch",
"pinSiPrefix": "k", "kp"
"suffix": "Ω", ],
"noUnitEditing": true, "lock":false
"compactHeight": false, },
"param": [ {
"s-h", "name":"Ki",
"ch", "type":"float",
"r0" "step":0.1,
], "suffix":"Hz",
"tip": "The resistance of the thermistor at base temperature T₀", "param":[
"lock": false "pid",
}, "ch",
{ "ki"
"name": "b", ],
"title": "B", "lock":false
"type": "float", },
"value": 3950, {
"step": 10, "name":"Kd",
"suffix": "K", "type":"float",
"noUnitEditing": true, "step":0.1,
"decimals": 4, "suffix":"s",
"compactHeight": false, "param":[
"param": [ "pid",
"s-h", "ch",
"ch", "kd"
"b" ],
], "lock":false
"tip": "The Beta Parameter", },
"lock": false {
} "name":"PID Output Clamping",
] "expanded":true,
}, "type":"group",
{ "children":[
"name": "postfilter", {
"title": "ADC Settings", "name":"Minimum",
"type": "group", "type":"float",
"tip": "Settings of the ADC on the SENS input", "step":100,
"children": [ "limits":[
{ -2000,
"name": "rate", 2000
"title": "50/60 Hz Rejection Filter", ],
"type": "list", "decimals":6,
"value": 16.67, "suffix":"mA",
"param": [ "param":[
"postfilter", "pid",
"ch", "ch",
"rate" "output_min"
], ],
"limits": { "lock":false
"16.67 SPS": 16.67, },
"20 SPS": 20.0, {
"21.25 SPS": 21.25, "name":"Maximum",
"27 SPS": 27.0, "type":"float",
"Off": null "step":100,
}, "limits":[
"tip": "Adjust the output data rate of the enhanced 50 Hz & 60 Hz rejection filter\n(Helps avoid mains interference)", -2000,
"lock": false 2000
} ],
] "decimals":6,
}, "suffix":"mA",
{ "param":[
"name": "pid", "pid",
"title": "PID Settings", "ch",
"expanded": false, "output_max"
"type": "group", ],
"tip": "Settings of PID parameters and clamping", "lock":false
"children": [ }
{ ]
"name": "kp", },
"title": "Kp", {
"type": "float", "name":"PID Auto Tune",
"step": 0.1, "expanded":false,
"compactHeight": false, "type":"group",
"param": [ "children":[
"pid", {
"ch", "name":"Target Temperature",
"kp" "type":"float",
], "value":20,
"tip": "Proportional gain", "step":0.1,
"lock": false "format":"{value:.4f} °C",
}, "pid_autotune":[
{ "target_temp",
"name": "ki", "ch"
"title": "Ki", ]
"type": "float", },
"step": 0.1, {
"suffix": "Hz", "name":"Test Current",
"noUnitEditing": true, "type":"float",
"compactHeight": false, "value":0,
"param": [ "decimals":6,
"pid", "step":100,
"ch", "limits":[
"ki" -2000,
], 2000
"tip": "Integral gain", ],
"lock": false "suffix":"mA",
}, "pid_autotune":[
{ "test_current",
"name": "kd", "ch"
"title": "Kd", ]
"type": "float", },
"step": 0.1, {
"suffix": "s", "name":"Temperature Swing",
"noUnitEditing": true, "type":"float",
"compactHeight": false, "value":1.5,
"param": [ "step":0.1,
"pid", "prefix":"±",
"ch", "format":"{value:.4f} °C",
"kd" "pid_autotune":[
], "temp_swing",
"tip": "Differential gain", "ch"
"lock": false ]
}, },
{ {
"name": "pid_output_clamping", "name":"Lookback",
"title": "PID Output Clamping", "type":"float",
"expanded": true, "value":3.0,
"type": "group", "step":0.1,
"tip": "Clamps PID outputs to specified range\nCould be different than output limits", "format":"{value:.4f} s",
"children": [ "pid_autotune":[
{
"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,
"param": [
"pid",
"ch",
"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,
"param": [
"pid",
"ch",
"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",
"ch"
],
"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",
"ch"
],
"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",
"ch"
],
"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", "lookback",
"ch" "ch"
], ]
"tip": "Amount of time referenced for tuning"
}, },
{ {
"name": "run_pid", "name":"Run",
"title": "Run", "type":"action",
"type": "action", "tip":"Run"
"tip": "Run PID Autotune with above settings" }
} ]
] }
} ]
] },
}, {
{ "name":"Save to flash",
"name": "save", "type":"action",
"title": "Save to flash", "tip":"Save config to thermostat, applies on reset"
"type": "action", },
"tip": "Save settings to thermostat, applies on reset" {
}, "name":"Load from flash",
{ "type":"action",
"name": "load", "tip":"Load config from flash"
"title": "Load from flash", }
"type": "action", ]
"tip": "Load settings from thermostat" }
}
]
}

View File

@ -75,8 +75,8 @@ class MainWindow(QtWidgets.QMainWindow):
self.thermostat.connection_error.connect(handle_connection_error) self.thermostat.connection_error.connect(handle_connection_error)
self.client.connection_error.connect(self.thermostat.timed_out) self.thermostat.connection_error.connect(self.thermostat.timed_out)
self.client.connection_error.connect(self.bail) self.thermostat.connection_error.connect(self.bail)
self.autotuners = PIDAutoTuner(self, self.thermostat, 2) self.autotuners = PIDAutoTuner(self, self.thermostat, 2)
@ -86,11 +86,11 @@ class MainWindow(QtWidgets.QMainWindow):
param_tree_sigActivated_handles = [ param_tree_sigActivated_handles = [
[ [
[["save"], partial(self.thermostat.save_cfg, ch)], [["Save to flash"], partial(self.thermostat.save_cfg, ch)],
[["load"], partial(self.thermostat.load_cfg, ch)], [["Load from flash"], partial(self.thermostat.load_cfg, ch)],
[ [
["pid", "pid_autotune", "run_pid"], ["PID Config", "PID Auto Tune", "Run"],
partial(self.pid_autotune_request, ch), partial(self.pid_auto_tune_request, ch),
], ],
] ]
for ch in range(self.NUM_CHANNELS) for ch in range(self.NUM_CHANNELS)
@ -276,21 +276,24 @@ class MainWindow(QtWidgets.QMainWindow):
@asyncSlot() @asyncSlot()
async def bail(self): async def bail(self):
await self._on_connection_changed(False) await self._on_connection_changed(False)
await self.thermostat.disconnect() await self.thermostat.end_session()
@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.value() ch = param.channel
for inner_param, change, data in changes: for inner_param, change, data in changes:
if change == "value": if change == "value":
if inner_param.opts.get("param", None) is not None: if inner_param.opts.get("param", None) is not None:
if inner_param.opts.get("suffix", None) == "mA":
data /= 1000 # Given in mA
thermostat_param = inner_param.opts["param"] thermostat_param = inner_param.opts["param"]
if thermostat_param[1] == "ch": if thermostat_param[1] == "ch":
thermostat_param[1] = ch thermostat_param[1] = ch
if inner_param.name() == "rate" and data is None: if inner_param.name() == "Postfilter Rate" and data is None:
set_param_args = (*thermostat_param[:2], "off") set_param_args = (*thermostat_param[:2], "off")
else: else:
set_param_args = (*thermostat_param, data) set_param_args = (*thermostat_param, data)
@ -299,26 +302,22 @@ class MainWindow(QtWidgets.QMainWindow):
param.child(*param.childPath(inner_param)).setOpts(lock=False) param.child(*param.childPath(inner_param)).setOpts(lock=False)
if inner_param.opts.get("pid_autotune", None) is not None: if inner_param.opts.get("pid_autotune", None) is not None:
autotuner_param = inner_param.opts["pid_autotune"][0] auto_tuner_param = inner_param.opts["pid_autotune"][0]
if inner_param.opts["pid_autotune"][1] != "ch": if inner_param.opts["pid_autotune"][1] != "ch":
ch = inner_param.opts["pid_autotune"][1] ch = inner_param.opts["pid_autotune"][1]
self.autotuners.set_params(autotuner_param, ch, data) self.autotuners.set_params(auto_tuner_param, ch, data)
if inner_param.opts.get("activaters", None) is not None: if inner_param.opts.get("activaters", None) is not None:
activater = inner_param.opts["activaters"][ activater = inner_param.opts["activaters"][
inner_param.reverse[0].index(data) # ListParameter.reverse = list of codename values inner_param.opts["limits"].index(data)
] ]
if activater is not None: if activater is not None:
if activater[1] == "ch": if activater[1] == "ch":
activater[1] = ch activater[1] = ch
await self.thermostat.set_param(*activater) await self.thermostat.set_param(*activater)
else:
await self.thermostat.set_param(
"pwm", ch, "i_set", inner_param.child("i_set").value()
)
@asyncSlot() @asyncSlot()
async def pid_autotune_request(self, ch=0): async def pid_auto_tune_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)
@ -339,7 +338,7 @@ 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", "pid_autotune", "run_pid"), "Run" ch, ("PID Config", "PID Auto Tune", "Run"), "Run"
) )
case ( case (
PIDAutotuneState.STATE_READY PIDAutotuneState.STATE_READY
@ -347,14 +346,14 @@ class MainWindow(QtWidgets.QMainWindow):
| PIDAutotuneState.STATE_RELAY_STEP_DOWN | PIDAutotuneState.STATE_RELAY_STEP_DOWN
): ):
self.ctrl_panel_view.change_params_title( self.ctrl_panel_view.change_params_title(
ch, ("pid", "pid_autotune", "run_pid"), "Stop" ch, ("PID Config", "PID Auto Tune", "Run"), "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 Settings has been loaded to Thermostat. Regulating temperature.", f"Channel {ch} PID Config has been loaded to Thermostat. Regulating temperature.",
) )
self.info_box.show() self.info_box.show()