Compare commits

...

3 Commits

Author SHA1 Message Date
d7dfb5d384 ctrl_panel: Pin down units for editable fields
User input always has the same order of magnitude, so allowing multiple
siPrefixes would be unwanted complexity. Don't allow them to be changed.

The Parameter option "noUnitEditing" is added to do so by the following
measures:

1. Don't validate for changed siPrefix (if pinned) and suffix, which
avoids their removal.

2. Avoid getting the cursor embedded within the unit.
2025-03-31 15:35:43 +08:00
f770f3fdfc ctrl_panel: Remove need for "mA" hack
Remove all instances of mA scaling scattered all around the code and
specify it in the parameter tree with a single source of truth.

Done by adding the option "pinSiPrefix" for all Parameters of type `int`
or `float`, and using it for current Parameters with unit "mA".
2025-03-31 15:23:55 +08:00
7c14663c0e Control Panel: Use SI-prefixed 'A' for tec_i unit
Allow the readonly display of current to vary its SI prefix in the unit,
since as a display entry it won't have the unit adjustment problem.
2025-03-31 15:13:46 +08:00
4 changed files with 231 additions and 39 deletions

View File

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

View File

@ -7,6 +7,7 @@ from pyqtgraph.parametertree import (
)
from qasync import asyncSlot
from pythermostat.autotune import PIDAutotuneState
import pythermostat.gui.view.lockable_unit
class MutexParameter(pTypes.ListParameter):
@ -139,9 +140,6 @@ class CtrlPanel(QObject):
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
@ -181,10 +179,10 @@ class CtrlPanel(QObject):
)
self.params[channel].child(
"PID Config", "PID Output Clamping", "Minimum"
).setValue(settings["parameters"]["output_min"] * 1000)
).setValue(settings["parameters"]["output_min"])
self.params[channel].child(
"PID Config", "PID Output Clamping", "Maximum"
).setValue(settings["parameters"]["output_max"] * 1000)
).setValue(settings["parameters"]["output_max"])
self.params[channel].child(
"Output Config", "Control Method", "Set Temperature"
).setValue(settings["target"])
@ -199,14 +197,14 @@ class CtrlPanel(QObject):
)
self.params[channel].child(
"Output Config", "Control Method", "Set Current"
).setValue(settings["i_set"] * 1000)
).setValue(settings["i_set"])
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
settings["tec_i"]
)
@pyqtSlot(list)
@ -234,10 +232,10 @@ class CtrlPanel(QObject):
).setValue(output_params["max_v"])
self.params[channel].child(
"Output Config", "Limits", "Max Cooling Current"
).setValue(output_params["max_i_pos"] * 1000)
).setValue(output_params["max_i_pos"])
self.params[channel].child(
"Output Config", "Limits", "Max Heating Current"
).setValue(output_params["max_i_neg"] * 1000)
).setValue(output_params["max_i_neg"])
@pyqtSlot(list)
def update_postfilter(self, postfilter_data):

View File

@ -0,0 +1,158 @@
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
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 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

@ -3,13 +3,15 @@
{
"name": "Temperature",
"type": "float",
"format": "{value:.4f} °C",
"format": "{value:.4f} {suffix}",
"suffix": "°C",
"readonly": true
},
{
"name": "Current through TEC",
"type": "float",
"suffix": "mA",
"siPrefix": true,
"suffix": "A",
"decimals": 6,
"readonly": true
},
@ -35,14 +37,17 @@
"name": "Set Current",
"type": "float",
"value": 0,
"step": 100,
"step": 0.1,
"limits": [
-2000,
2000
-2,
2
],
"triggerOnShow": true,
"decimals": 6,
"suffix": "mA",
"pinSiPrefix": "m",
"suffix": "A",
"siPrefix": true,
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "output",
@ -59,7 +64,9 @@
-273,
300
],
"format": "{value:.4f} °C",
"format": "{value:.4f} {suffix}",
"suffix": "°C",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
@ -78,13 +85,16 @@
"name": "Max Cooling Current",
"type": "float",
"value": 0,
"step": 100,
"step": 0.1,
"decimals": 6,
"limits": [
0,
2000
2
],
"suffix": "mA",
"siPrefix": true,
"pinSiPrefix": "m",
"suffix": "A",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "output",
@ -96,13 +106,16 @@
"name": "Max Heating Current",
"type": "float",
"value": 0,
"step": 100,
"step": 0.1,
"decimals": 6,
"siPrefix": true,
"pinSiPrefix": "m",
"suffix": "A",
"noUnitEditing": true,
"limits": [
0,
2000
2
],
"suffix": "mA",
"compactHeight": false,
"thermostat:set_param": {
"topic": "output",
@ -121,6 +134,7 @@
4.3
],
"suffix": "V",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "output",
@ -146,7 +160,9 @@
-100,
100
],
"format": "{value:.4f} °C",
"format": "{value:.4f} {suffix}",
"suffix": "°C",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "b-p",
@ -160,7 +176,9 @@
"value": 10000,
"step": 1,
"siPrefix": true,
"pinSiPrefix": "k",
"suffix": "Ω",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "b-p",
@ -174,6 +192,7 @@
"value": 3950,
"step": 1,
"suffix": "K",
"noUnitEditing": true,
"decimals": 4,
"compactHeight": false,
"thermostat:set_param": {
@ -223,6 +242,7 @@
"type": "float",
"step": 0.1,
"suffix": "Hz",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
@ -235,6 +255,7 @@
"type": "float",
"step": 0.1,
"suffix": "s",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
@ -250,13 +271,16 @@
{
"name": "Minimum",
"type": "float",
"step": 100,
"step": 0.1,
"limits": [
-2000,
2000
-2,
2
],
"decimals": 6,
"suffix": "mA",
"siPrefix": true,
"pinSiPrefix": "m",
"suffix": "A",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
@ -267,13 +291,16 @@
{
"name": "Maximum",
"type": "float",
"step": 100,
"step": 0.1,
"limits": [
-2000,
2000
-2,
2
],
"decimals": 6,
"suffix": "mA",
"siPrefix": true,
"pinSiPrefix": "m",
"suffix": "A",
"noUnitEditing": true,
"compactHeight": false,
"thermostat:set_param": {
"topic": "pid",
@ -293,7 +320,9 @@
"type": "float",
"value": 20,
"step": 0.1,
"format": "{value:.4f} °C",
"format": "{value:.4f} {suffix}",
"suffix": "°C",
"noUnitEditing": true,
"compactHeight": false,
"pid_autotune": "target_temp"
},
@ -302,12 +331,15 @@
"type": "float",
"value": 0,
"decimals": 6,
"step": 100,
"step": 0.1,
"limits": [
-2000,
2000
-2,
2
],
"suffix": "mA",
"siPrefix": true,
"pinSiPrefix": "m",
"suffix": "A",
"noUnitEditing": true,
"compactHeight": false,
"pid_autotune": "test_current"
},
@ -317,7 +349,9 @@
"value": 1.5,
"step": 0.1,
"prefix": "±",
"format": "{value:.4f} °C",
"format": "{value:.4f} {suffix}",
"suffix": "°C",
"noUnitEditing": true,
"compactHeight": false,
"pid_autotune": "temp_swing"
},
@ -326,7 +360,9 @@
"type": "float",
"value": 3.0,
"step": 0.1,
"format": "{value:.4f} s",
"format": "{value:.4f} {suffix}",
"noUnitEditing": true,
"suffix": "s",
"compactHeight": false,
"pid_autotune": "lookback"
},