diff --git a/README.md b/README.md index 10d2fc7..7106f06 100644 --- a/README.md +++ b/README.md @@ -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" ``` -## 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 +``` + +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 @@ -178,7 +192,7 @@ postfilter rate can be tuned with the `postfilter` command. - Connect TEC module device 1 to TEC1- and TEC1+. - 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. diff --git a/flake.nix b/flake.nix index c28f806..2701408 100644 --- a/flake.nix +++ b/flake.nix @@ -71,8 +71,13 @@ rustPlatform.rust.cargo openocd dfu-util ] ++ (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} + ''; }; defaultPackage.x86_64-linux = thermostat; }; diff --git a/pytec/autotune.py b/pytec/autotune.py index c1f593e..bf12432 100644 --- a/pytec/autotune.py +++ b/pytec/autotune.py @@ -17,6 +17,7 @@ class PIDAutotuneState(Enum): STATE_RELAY_STEP_DOWN = 'relay step down' STATE_SUCCEEDED = 'succeeded' STATE_FAILED = 'failed' + STATE_READY = 'ready' class PIDAutotune: @@ -56,6 +57,20 @@ class PIDAutotune: self._Ku = 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): """Get the current state.""" return self._state @@ -81,6 +96,13 @@ class PIDAutotune: kd = divisors[2] * self._Ku * self._Pu 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): """To autotune a system, this method must be called periodically. @@ -95,7 +117,8 @@ class PIDAutotune: if (self._state == PIDAutotuneState.STATE_OFF 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._last_run_timestamp = now diff --git a/pytec/pytec/client.py b/pytec/pytec/client.py index 642e831..4d9eac6 100644 --- a/pytec/pytec/client.py +++ b/pytec/pytec/client.py @@ -23,7 +23,7 @@ class Client: return line 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() response = json.loads(line) diff --git a/pytec/tecQT.py b/pytec/tecQT.py new file mode 100644 index 0000000..4a94439 --- /dev/null +++ b/pytec/tecQT.py @@ -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 = [ [ + {'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(): + 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 + + +if __name__ == '__main__': + tec = Client(host="192.168.1.26", port=23, timeout=None) + + 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) + 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() \ No newline at end of file diff --git a/src/channels.rs b/src/channels.rs index 7aa34e9..c26a0e2 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -113,7 +113,12 @@ impl Channels { } 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); let r_sense = ElectricalResistance::new::(R_SENSE); let voltage = self.get_dac(channel); let i_tec = (voltage - center_point) / (10.0 * r_sense);