GUI #70

Open
topquark12 wants to merge 17 commits from GUI into master
6 changed files with 361 additions and 6 deletions

View File

@ -67,7 +67,21 @@ On a Windows machine install [st.com](https://st.com) DfuSe USB device firmware
openocd -f interface/stlink-v2-1.cfg -f target/stm32f4x.cfg -c "program target/thumbv7em-none-eabihf/release/thermostat verify reset;exit" openocd -f interface/stlink-v2-1.cfg -f target/stm32f4x.cfg -c "program target/thumbv7em-none-eabihf/release/thermostat verify reset;exit"
``` ```
## Network ## GUI Usage
A GUI has been developed for easy configuration and plotting of key parameters.
The Python GUI program is located at pytec/tecQT.py
The GUI is developed based on the Python library pyqtgraph. The environment needed to run the GUI is configured automatically by running:
```shell
nix develop
Review

You could make it a nix shell

You could make it a ``nix shell``
```
The GUI program assumes the default IP and port of 192.168.1.26 23 is used. If a different IP or port is used, the IP and port setting should be changed in the GUI code.
## Command Line Usage
### Connecting ### Connecting
@ -178,7 +192,7 @@ postfilter rate can be tuned with the `postfilter` command.
- Connect TEC module device 1 to TEC1- and TEC1+. - Connect TEC module device 1 to TEC1- and TEC1+.
- The GND pin is for shielding not for sinking TEC module currents. - The GND pin is for shielding not for sinking TEC module currents.
When using a TEC module with the Thermostat, the Thermostat expects the thermal load (where the thermistor is located) to heat up with a positive software current set point, and cool down with a negative current set point. When using a TEC module with the Thermostat, the Thermostat expects the thermal load (where the thermistor is located) to cool down with a positive software current set point, and heat up with a negative current set point.
Testing heat flow direction with a low set current is recommended before installation of the TEC module. Testing heat flow direction with a low set current is recommended before installation of the TEC module.

View File

@ -71,8 +71,13 @@
rustPlatform.rust.cargo rustPlatform.rust.cargo
openocd dfu-util openocd dfu-util
] ++ (with python3Packages; [ ] ++ (with python3Packages; [
numpy matplotlib numpy matplotlib pyqtgraph
]); ]);
shellHook=
''
export QT_PLUGIN_PATH=${pkgs.qt5.qtbase}/${pkgs.qt5.qtbase.dev.qtPluginPrefix}
export QML2_IMPORT_PATH=${pkgs.qt5.qtbase}/${pkgs.qt5.qtbase.dev.qtQmlPrefix}
Review

Better solution: package tecQT (making a separate flake output) and use wrapQtApp

Better solution: package tecQT (making a separate flake output) and use wrapQtApp
'';
}; };
defaultPackage.x86_64-linux = thermostat; defaultPackage.x86_64-linux = thermostat;
}; };

View File

@ -17,6 +17,7 @@ class PIDAutotuneState(Enum):
STATE_RELAY_STEP_DOWN = 'relay step down' STATE_RELAY_STEP_DOWN = 'relay step down'
STATE_SUCCEEDED = 'succeeded' STATE_SUCCEEDED = 'succeeded'
STATE_FAILED = 'failed' STATE_FAILED = 'failed'
STATE_READY = 'ready'
class PIDAutotune: class PIDAutotune:
@ -56,6 +57,20 @@ class PIDAutotune:
self._Ku = 0 self._Ku = 0
self._Pu = 0 self._Pu = 0
def setParam(self, target, step, noiseband, sampletime, lookback):
self._setpoint = target
self._outputstep = step
self._out_max = step
self._out_min = -step
self._noiseband = noiseband
self._inputs = deque(maxlen=round(lookback / sampletime))
def setReady(self):
self._state = PIDAutotuneState.STATE_READY
def setOff(self):
self._state = PIDAutotuneState.STATE_OFF
def state(self): def state(self):
"""Get the current state.""" """Get the current state."""
return self._state return self._state
@ -81,6 +96,13 @@ class PIDAutotune:
kd = divisors[2] * self._Ku * self._Pu kd = divisors[2] * self._Ku * self._Pu
return PIDAutotune.PIDParams(kp, ki, kd) return PIDAutotune.PIDParams(kp, ki, kd)
def get_tec_pid (self):
divisors = self._tuning_rules["tyreus-luyben"]
kp = self._Ku * divisors[0]
ki = divisors[1] * self._Ku / self._Pu
kd = divisors[2] * self._Ku * self._Pu
return kp, ki, kd
def run(self, input_val, time_input): def run(self, input_val, time_input):
"""To autotune a system, this method must be called periodically. """To autotune a system, this method must be called periodically.
@ -95,7 +117,8 @@ class PIDAutotune:
if (self._state == PIDAutotuneState.STATE_OFF if (self._state == PIDAutotuneState.STATE_OFF
or self._state == PIDAutotuneState.STATE_SUCCEEDED or self._state == PIDAutotuneState.STATE_SUCCEEDED
or self._state == PIDAutotuneState.STATE_FAILED): or self._state == PIDAutotuneState.STATE_FAILED
or self._state == PIDAutotuneState.STATE_READY):
self._state = PIDAutotuneState.STATE_RELAY_STEP_UP self._state = PIDAutotuneState.STATE_RELAY_STEP_UP
self._last_run_timestamp = now self._last_run_timestamp = now

View File

@ -23,7 +23,7 @@ class Client:
return line return line
def _command(self, *command): def _command(self, *command):
self._socket.sendall((" ".join(command) + "\n").encode('utf-8')) self._socket.sendall(((" ".join(command)).strip() + "\n").encode('utf-8'))
line = self._read_line() line = self._read_line()
response = json.loads(line) response = json.loads(line)

308
pytec/tecQT.py Normal file
View File

@ -0,0 +1,308 @@
from pyqtgraph.Qt import QtGui, QtCore
import pyqtgraph.parametertree.parameterTypes as pTypes
from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType
import numpy as np
import pyqtgraph as pg
from pytec.client import Client
from enum import Enum
from autotune import PIDAutotune, PIDAutotuneState
rec_len = 1000
refresh_period = 20
TECparams = [ [
Review

tec_params, PEP8 and consistency with the two variables above

tec_params, PEP8 and consistency with the two variables above
{'tag': 'report', 'type': 'parent', 'children': [
{'tag': 'pid_engaged', 'type': 'bool', 'value': False},
]},
{'tag': 'pwm', 'type': 'parent', 'children': [
{'tag': 'max_i_pos', 'type': 'float', 'value': 0},
{'tag': 'max_i_neg', 'type': 'float', 'value': 0},
{'tag': 'max_v', 'type': 'float', 'value': 0},
{'tag': 'i_set', 'type': 'float', 'value': 0},
]},
{'tag': 'pid', 'type': 'parent', 'children': [
{'tag': 'kp', 'type': 'float', 'value': 0},
{'tag': 'ki', 'type': 'float', 'value': 0},
{'tag': 'kd', 'type': 'float', 'value': 0},
{'tag': 'output_min', 'type': 'float', 'value': 0},
{'tag': 'output_max', 'type': 'float', 'value': 0},
]},
{'tag': 's-h', 'type': 'parent', 'children': [
{'tag': 't0', 'type': 'float', 'value': 0},
{'tag': 'r0', 'type': 'float', 'value': 0},
{'tag': 'b', 'type': 'float', 'value': 0},
]},
{'tag': 'PIDtarget', 'type': 'parent', 'children': [
{'tag': 'target', 'type': 'float', 'value': 0},
]},
] for _ in range(2)]
GUIparams = [[
{'name': 'Disable Output', 'type': 'action', 'tip': 'Disable Output'},
{'name': 'Constant Current', 'type': 'group', 'children': [
{'name': 'Set Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (-3, 3), 'siPrefix': True, 'suffix': 'A'},
]},
{'name': 'Temperature PID', 'type': 'bool', 'value': False, 'children': [
{'name': 'Set Temperature', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-273, 300), 'siPrefix': True, 'suffix': 'C'},
]},
{'name': 'Output Config', 'expanded': False, 'type': 'group', 'children': [
{'name': 'Max Current', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 3), 'siPrefix': True, 'suffix': 'A'},
{'name': 'Max Voltage', 'type': 'float', 'value': 0, 'step': 0.1, 'limits': (0, 5), 'siPrefix': True, 'suffix': 'V'},
]},
{'name': 'Thermistor Config', 'expanded': False, 'type': 'group', 'children': [
{'name': 'T0', 'type': 'float', 'value': 25, 'step': 0.1, 'limits': (-100, 100), 'siPrefix': True, 'suffix': 'C'},
{'name': 'R0', 'type': 'float', 'value': 10000, 'step': 1, 'siPrefix': True, 'suffix': 'Ohm'},
{'name': 'Beta', 'type': 'float', 'value': 3950, 'step': 1},
]},
{'name': 'PID Config', 'expanded': False, 'type': 'group', 'children': [
{'name': 'kP', 'type': 'float', 'value': 0, 'step': 0.1},
{'name': 'kI', 'type': 'float', 'value': 0, 'step': 0.1},
{'name': 'kD', 'type': 'float', 'value': 0, 'step': 0.1},
{'name': 'PID Auto Tune', 'expanded': False, 'type': 'group', 'children': [
{'name': 'Target Temperature', 'type': 'float', 'value': 20, 'step': 0.1, 'siPrefix': True, 'suffix': 'C'},
{'name': 'Test Current', 'type': 'float', 'value': 1, 'step': 0.1, 'siPrefix': True, 'suffix': 'A'},
{'name': 'Temperature Swing', 'type': 'float', 'value': 1.5, 'step': 0.1, 'siPrefix': True, 'suffix': 'C'},
{'name': 'Run', 'type': 'action', 'tip': 'Run'},
]},
]},
{'name': 'Save to flash', 'type': 'action', 'tip': 'Save to flash'},
] for _ in range(2)]
autoTuner = [PIDAutotune(20, 1, 1, 1.5, refresh_period / 1000),
PIDAutotune(20, 1, 1, 1.5, refresh_period / 1000)]
## If anything changes in the tree, print a message
def change(param, changes, ch):
print("tree changes:")
for param, change, data in changes:
path = paramList[ch].childPath(param)
if path is not None:
childName = '.'.join(path)
else:
childName = param.name()
print(' parameter: %s'% childName)
print(' change: %s'% change)
print(' data: %s'% str(data))
print(' ----------')
if (childName == 'Disable Output'):
tec.set_param('pwm', ch, 'i_set', 0)
paramList[ch].child('Constant Current').child('Set Current').setValue(0)
paramList[ch].child('Temperature PID').setValue(False)
autoTuner[ch].setOff()
if (childName == 'Temperature PID'):
if (data):
tec.set_param("pwm", ch, "pid")
else:
tec.set_param('pwm', ch, 'i_set', paramList[ch].child('Constant Current').child('Set Current').value())
if (childName == 'Constant Current.Set Current'):
tec.set_param('pwm', ch, 'i_set', data)
paramList[ch].child('Temperature PID').setValue(False)
if (childName == 'Temperature PID.Set Temperature'):
tec.set_param('pid', ch, 'target', data)
if (childName == 'Output Config.Max Current'):
tec.set_param('pwm', ch, 'max_i_pos', data)
tec.set_param('pwm', ch, 'max_i_neg', data)
tec.set_param('pid', ch, 'output_min', -data)
tec.set_param('pid', ch, 'output_max', data)
if (childName == 'Output Config.Max Voltage'):
tec.set_param('pwm', ch, 'max_v', data)
if (childName == 'Thermistor Config.T0'):
tec.set_param('s-h', ch, 't0', data)
if (childName == 'Thermistor Config.R0'):
tec.set_param('s-h', ch, 'r0', data)
if (childName == 'Thermistor Config.Beta'):
tec.set_param('s-h', ch, 'b', data)
if (childName == 'PID Config.kP'):
tec.set_param('pid', ch, 'kp', data)
if (childName == 'PID Config.kI'):
tec.set_param('pid', ch, 'ki', data)
if (childName == 'PID Config.kD'):
tec.set_param('pid', ch, 'kd', data)
if (childName == 'PID Config.PID Auto Tune.Run'):
autoTuner[ch].setParam(paramList[ch].child('PID Config').child('PID Auto Tune').child('Target Temperature').value(),
paramList[ch].child('PID Config').child('PID Auto Tune').child('Test Current').value(),
paramList[ch].child('PID Config').child('PID Auto Tune').child('Temperature Swing').value(),
refresh_period / 1000,
1)
autoTuner[ch].setReady()
paramList[ch].child('Temperature PID').setValue(False)
if (childName == 'Save to flash'):
tec.save_config()
def change0(param, changes):
change(param, changes, 0)
def change1(param, changes):
change(param, changes, 1)
class Curves:
def __init__(self, legend: str, key: str, channel: int, color: str, buffer_len: int, period: int):
self.curveItem = pg.PlotCurveItem(pen=({'color': color, 'width': 1}))
self.legendStr = legend
self.keyStr = key
self.channel = channel
self.data_buf = np.zeros(buffer_len)
self.time_stamp = np.zeros(buffer_len)
self.buffLen = buffer_len
self.period = period
def update(self, tec_data, cnt):
if cnt == 0:
np.copyto(self.data_buf, np.full(self.buffLen, tec_data[self.channel][self.keyStr]))
else:
self.data_buf[:-1] = self.data_buf[1:]
self.data_buf[-1] = tec_data[self.channel][self.keyStr]
self.time_stamp[:-1] = self.time_stamp[1:]
self.time_stamp[-1] = cnt * self.period / 1000
self.curveItem.setData(x = self.time_stamp, y = self.data_buf)
class Graph:
def __init__(self, parent: pg.LayoutWidget, title: str, row: int, col: int, curves: list[Curves]):
self.plotItem = pg.PlotWidget(title=title)
self.legendItem = pg.LegendItem(offset=(75, 30), brush=(50,50,200,150))
self.legendItem.setParentItem(self.plotItem.getPlotItem())
parent.addWidget(self.plotItem, row, col)
self.curves = curves
for curve in self.curves:
self.plotItem.addItem(curve.curveItem)
self.legendItem.addItem(curve.curveItem, curve.legendStr)
def update(self, tec_data, cnt):
for curve in self.curves:
curve.update(tec_data, cnt)
self.plotItem.setRange(xRange=[(cnt - self.curves[0].buffLen) * self.curves[0].period / 1000, cnt * self.curves[0].period / 1000])
def TECsync():
global TECparams
for channel in range(2):
for parents in TECparams[channel]:
if parents['tag'] == 'report':
for data in tec.report_mode():
for children in parents['children']:
print(data)
children['value'] = data[channel][children['tag']]
if quit:
break
if parents['tag'] == 'pwm':
for children in parents['children']:
children['value'] = tec.get_pwm()[channel][children['tag']]['value']
if parents['tag'] == 'pid':
for children in parents['children']:
children['value'] = tec.get_pid()[channel]['parameters'][children['tag']]
if parents['tag'] == 's-h':
for children in parents['children']:
children['value'] = tec.get_steinhart_hart()[channel]['params'][children['tag']]
if parents['tag'] == 'PIDtarget':
for children in parents['children']:
children['value'] = tec.get_pid()[channel]['target']
def refreshTreeParam(tempTree:dict, channel:int) -> dict:
tempTree['children']['Constant Current']['children']['Set Current']['value'] = TECparams[channel][1]['children'][3]['value']
tempTree['children']['Temperature PID']['value'] = TECparams[channel][0]['children'][0]['value']
tempTree['children']['Temperature PID']['children']['Set Temperature']['value'] = TECparams[channel][4]['children'][0]['value']
tempTree['children']['Output Config']['children']['Max Current']['value'] = TECparams[channel][1]['children'][0]['value']
tempTree['children']['Output Config']['children']['Max Voltage']['value'] = TECparams[channel][1]['children'][2]['value']
tempTree['children']['Thermistor Config']['children']['T0']['value'] = TECparams[channel][3]['children'][0]['value'] - 273.15
tempTree['children']['Thermistor Config']['children']['R0']['value'] = TECparams[channel][3]['children'][1]['value']
tempTree['children']['Thermistor Config']['children']['Beta']['value'] = TECparams[channel][3]['children'][2]['value']
tempTree['children']['PID Config']['children']['kP']['value'] = TECparams[channel][2]['children'][0]['value']
tempTree['children']['PID Config']['children']['kI']['value'] = TECparams[channel][2]['children'][1]['value']
tempTree['children']['PID Config']['children']['kD']['value'] = TECparams[channel][2]['children'][2]['value']
return tempTree
cnt = 0
def updateData():
Review

update_data, see PEP8

update_data, see PEP8
global cnt
for data in tec.report_mode():
ch0tempGraph.update(data, cnt)
ch1tempGraph.update(data, cnt)
ch0currentGraph.update(data, cnt)
ch1currentGraph.update(data, cnt)
for channel in range (2):
if (autoTuner[channel].state() == PIDAutotuneState.STATE_READY or
autoTuner[channel].state() == PIDAutotuneState.STATE_RELAY_STEP_UP or
autoTuner[channel].state() == PIDAutotuneState.STATE_RELAY_STEP_DOWN):
autoTuner[channel].run(data[channel]['temperature'], data[channel]['time'])
tec.set_param('pwm', channel, 'i_set', autoTuner[channel].output())
elif (autoTuner[channel].state() == PIDAutotuneState.STATE_SUCCEEDED):
kp, ki, kd = autoTuner[channel].get_tec_pid()
autoTuner[channel].setOff()
paramList[channel].child('PID Config').child('kP').setValue(kp)
paramList[channel].child('PID Config').child('kI').setValue(ki)
paramList[channel].child('PID Config').child('kD').setValue(kd)
tec.set_param('pid', channel, 'kp', kp)
tec.set_param('pid', channel, 'ki', ki)
tec.set_param('pid', channel, 'kd', kd)
elif (autoTuner[channel].state() == PIDAutotuneState.STATE_FAILED):
tec.set_param('pwm', channel, 'i_set', 0)
autoTuner[channel].setOff()
if quit:
break
cnt += 1
Review

Should there be a feature to reset the plots and/or set a maximum number of samples to store?

Should there be a feature to reset the plots and/or set a maximum number of samples to store?
if __name__ == '__main__':
tec = Client(host="192.168.1.26", port=23, timeout=None)
Review

argparse, IP address settings

argparse, IP address settings
app = pg.mkQApp()
pg.setConfigOptions(antialias=True)
mw = QtGui.QMainWindow()
mw.setWindowTitle('Thermostat Control Panel')
mw.resize(1920,1200)
cw = QtGui.QWidget()
mw.setCentralWidget(cw)
l = QtGui.QVBoxLayout()
layout = pg.LayoutWidget()
l.addWidget(layout)
cw.setLayout(l)
## Create tree of Parameter objects
paramList = [Parameter.create(name='GUIparams', type='group', children=GUIparams[0]),
Parameter.create(name='GUIparams', type='group', children=GUIparams[1])]
ch0Tree = ParameterTree()
ch0Tree.setParameters(paramList[0], showTop=False)
ch1Tree = ParameterTree()
ch1Tree.setParameters(paramList[1], showTop=False)
TECsync()
paramList[0].restoreState(refreshTreeParam(paramList[0].saveState(), 0))
paramList[1].restoreState(refreshTreeParam(paramList[1].saveState(), 1))
paramList[0].sigTreeStateChanged.connect(change0)
Review

Make channel the first parameter, use functools.partial, and remove change0/change1

Make ``channel`` the first parameter, use ``functools.partial``, and remove ``change0/change1``
paramList[1].sigTreeStateChanged.connect(change1)
layout.addWidget(ch0Tree, 1, 1, 1, 1)
layout.addWidget(ch1Tree, 2, 1, 1, 1)
ch0tempGraph = Graph(layout, 'Channel 0 Temperature', 1, 2, [Curves('Feedback', 'temperature', 0, 'r', rec_len, refresh_period)])
ch1tempGraph = Graph(layout, 'Channel 1 Temperature', 2, 2, [Curves('Feedback', 'temperature', 1, 'r', rec_len, refresh_period)])
ch0currentGraph = Graph(layout, 'Channel 0 Current', 1, 3, [Curves('Feedback', 'tec_i', 0, 'r', rec_len, refresh_period),
Curves('Setpoint', 'i_set', 0, 'g', rec_len, refresh_period)])
ch1currentGraph = Graph(layout, 'Channel 1 Current', 2, 3, [Curves('Feedback', 'tec_i', 1, 'r', rec_len, refresh_period),
Curves('Setpoint', 'i_set', 1, 'g', rec_len, refresh_period)])
t = QtCore.QTimer()
t.timeout.connect(updateData)
t.start(refresh_period)
mw.show()
pg.exec()

View File

@ -113,7 +113,12 @@ impl Channels {
} }
pub fn get_i(&mut self, channel: usize) -> ElectricCurrent { pub fn get_i(&mut self, channel: usize) -> ElectricCurrent {
let center_point = self.get_center(channel); let center_point = match channel.into() {
0 => self.channel0.vref_meas,
1 => self.channel1.vref_meas,
_ => unreachable!(),
};
// let center_point = self.get_center(channel);
Review

Why?
If the get_center function is broken then it should be fixed. In any case this commented-out line should be removed.

Why? If the get_center function is broken then it should be fixed. In any case this commented-out line should be removed.
let r_sense = ElectricalResistance::new::<ohm>(R_SENSE); let r_sense = ElectricalResistance::new::<ohm>(R_SENSE);
let voltage = self.get_dac(channel); let voltage = self.get_dac(channel);
let i_tec = (voltage - center_point) / (10.0 * r_sense); let i_tec = (voltage - center_point) / (10.0 * r_sense);