Compare commits

..

10 Commits

Author SHA1 Message Date
348f2600f3 README: Introduce Thermostat GUI
Co-authored-by: topquark12 <aw@m-labs.hk>
2024-11-18 15:28:09 +08:00
57ffb096ce pytec GUI: Set up packaging
Co-authored-by: Egor Savkin <es@m-labs.hk>
2024-11-18 15:28:09 +08:00
cfde82bdcd pytec GUI: Implement Control Panel
Co-authored-by: linuswck <linuswck@m-labs.hk>
Co-authored-by: Egor Savkin <es@m-labs.hk>
2024-11-18 15:28:09 +08:00
26d0fe88ec pytec GUI: Implement PlotSettingsMenu
Co-authored-by: linuswck <linuswck@m-labs.hk>
2024-11-18 15:28:09 +08:00
b2c63d6f0f pytec GUI: Implement plotting
Co-authored-by: linuswck <linuswck@m-labs.hk>
2024-11-18 15:28:09 +08:00
526c7aac5a pytec GUI: Incorporate autotuning
Co-authored-by: topquark12 <aw@m-labs.hk>
Co-authored-by: linuswck <linuswck@m-labs.hk>
Co-authored-by: Egor Savkin <es@m-labs.hk>
2024-11-18 15:28:09 +08:00
89b5fc9340 pytec GUI: Implement ThermostatSettingsMenu
Co-authored-by: linuswck <linuswck@m-labs.hk>
Co-authored-by: Egor Savkin <es@m-labs.hk>
2024-11-18 15:28:09 +08:00
e24d8b23d0 pytec GUI: Implement status line
Co-authored-by: linuswck <linuswck@m-labs.hk>
Co-authored-by: Egor Savkin <es@m-labs.hk>
2024-11-18 15:28:09 +08:00
68749d445c pytec: Create GUI to Thermostat
- Add connection menu

- Add basic GUI layout skeleton

Co-authored-by: linuswck <linuswck@m-labs.hk>
Co-authored-by: Egor Savkin <es@m-labs.hk>
2024-11-18 15:28:09 +08:00
44f2762887 pytec: Create asyncio clients 2024-11-18 15:28:09 +08:00
32 changed files with 196 additions and 205 deletions

View File

@ -71,7 +71,7 @@ openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program target/thumbv
A GUI has been developed for easy configuration and plotting of key parameters.
The Python GUI program is located at pythermostat/pythermostat/thermostat_qt.py, and is developed based on the Python library pyqtgraph. The GUI can be configured and
The Python GUI program is located at pytec/tec_qt.py, and is developed based on the Python library pyqtgraph. The GUI can be configured and
launched automatically by running:
```
@ -250,22 +250,6 @@ of channel 0 to the PID algorithm:
output 0 pid
```
### PID output clamping
It is possible to clamp the PID algorithm output independently of channel output limits. This is desirable when e.g. there is a need to keep the current value above a certain threshold in closed-loop mode.
Note that the actual output will still ultimately be limited by the `max_i_pos` and `max_i_neg` values.
Set PID maximum output of channel 0 to 1.5 A.
```
pid 0 output_max 1.5
```
Set PID minimum output of channel 0 to 0.1 A.
```
pid 0 output_min 0.1
```
## LED indicators
| Name | Color | Meaning |

View File

@ -13,7 +13,7 @@ When tuning Thermostat PID parameters, it is helpful to view the temperature, PI
To use the Python real-time plotting utility, run
```shell
python pythermostat/pythermostat/plot.py
python pytec/plot.py
```
![default view](./assets/default%20view.png)
@ -44,12 +44,12 @@ Below are some general guidelines for manually tuning PID loops. Note that every
## Auto Tuning
A PID auto tuning utility is provided in the PyThermostat library. The auto tuning utility drives the the load to a controlled oscillation, observes the ultimate gain and oscillation period and calculates a set of PID parameters.
A PID auto tuning utility is provided in the Pytec library. The auto tuning utility drives the the load to a controlled oscillation, observes the ultimate gain and oscillation period and calculates a set of PID parameters.
To run the auto tuning utility, run
```shell
python pythermostat/pythermostat/autotune.py
python pytec/autotune.py
```
After some time, the auto tuning utility will output the auto tuning results, below is a sample output

View File

@ -58,11 +58,11 @@
auditable = false;
};
pythermostat = pkgs.python3Packages.buildPythonPackage {
pname = "pythermostat";
pytec = pkgs.python3Packages.buildPythonPackage {
pname = "pytec";
version = "0.0.0";
format = "pyproject";
src = "${self}/pythermostat";
src = "${self}/pytec";
nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ];
propagatedBuildInputs =
@ -99,13 +99,13 @@
in
{
packages.x86_64-linux = {
inherit thermostat pythermostat;
inherit thermostat pytec;
default = thermostat;
};
apps.x86_64-linux.thermostat_gui = {
type = "app";
program = "${self.packages.x86_64-linux.pythermostat}/bin/tec_qt";
program = "${self.packages.x86_64-linux.pytec}/bin/tec_qt";
};
hydraJobs = {

4
pytec/MANIFEST.in Normal file
View File

@ -0,0 +1,4 @@
graft examples
include pytec/gui/resources/artiq.ico
include pytec/gui/view/param_tree.json
include pytec/gui/view/tec_qt.ui

View File

@ -1,10 +1,9 @@
import math
import logging
import time
from collections import deque, namedtuple
from enum import Enum
from pythermostat.client import Client
from pytec.client import Client
# Based on hirshmann pid-autotune libiary
# See https://github.com/hirschmann/pid-autotune
@ -260,14 +259,13 @@ def main():
tec = Client()
data = tec.get_report()
data = next(tec.report_mode())
ch = data[channel]
tuner = PIDAutotune(target_temperature, output_step,
lookback, noiseband, ch['interval'])
while True:
data = tec.get_report()
for data in tec.report_mode():
ch = data[channel]
@ -280,8 +278,6 @@ def main():
tec.set_param("output", channel, "i_set", tuner_out)
time.sleep(0.05)
tec.set_param("output", channel, "i_set", 0)

View File

@ -1,6 +1,6 @@
import asyncio
from contextlib import suppress
from pythermostat.aioclient import AsyncioClient
from pytec.aioclient import AsyncioClient
async def poll_for_info(tec):

View File

@ -1,5 +1,5 @@
import time
from pythermostat.client import Client
from pytec.client import Client
tec = Client() #(host="localhost", port=6667)
tec.set_param("b-p", 1, "t0", 20)

131
pytec/plot.py Normal file
View File

@ -0,0 +1,131 @@
import time
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from threading import Thread, Lock
from pytec.client import Client
TIME_WINDOW = 300.0
tec = Client()
target_temperature = tec.get_pid()[0]['target']
print("Channel 0 target temperature: {:.3f}".format(target_temperature))
class Series:
def __init__(self, conv=lambda x: x):
self.conv = conv
self.x_data = []
self.y_data = []
def append(self, x, y):
self.x_data.append(x)
self.y_data.append(self.conv(y))
def clip(self, min_x):
drop = 0
while drop < len(self.x_data) and self.x_data[drop] < min_x:
drop += 1
self.x_data = self.x_data[drop:]
self.y_data = self.y_data[drop:]
series = {
# 'adc': Series(),
# 'sens': Series(lambda x: x * 0.0001),
'temperature': Series(),
# 'i_set': Series(),
'pid_output': Series(),
# 'vref': Series(),
# 'dac_value': Series(),
# 'dac_feedback': Series(),
# 'i_tec': Series(),
'tec_i': Series(),
'tec_u_meas': Series(),
# 'interval': Series(),
}
series_lock = Lock()
quit = False
def recv_data(tec):
global last_packet_time
while True:
data = tec.get_report()
ch0 = data[0]
series_lock.acquire()
try:
for k, s in series.items():
if k in ch0:
v = ch0[k]
if type(v) is float:
s.append(ch0['time'], v)
finally:
series_lock.release()
if quit:
break
time.sleep(0.05)
thread = Thread(target=recv_data, args=(tec,))
thread.start()
fig, ax = plt.subplots()
for k, s in series.items():
s.plot, = ax.plot([], [], label=k)
legend = ax.legend()
def animate(i):
min_x, max_x, min_y, max_y = None, None, None, None
series_lock.acquire()
try:
for k, s in series.items():
s.plot.set_data(s.x_data, s.y_data)
if len(s.y_data) > 0:
s.plot.set_label("{}: {:.3f}".format(k, s.y_data[-1]))
if len(s.x_data) > 0:
min_x_ = min(s.x_data)
if min_x is None:
min_x = min_x_
else:
min_x = min(min_x, min_x_)
max_x_ = max(s.x_data)
if max_x is None:
max_x = max_x_
else:
max_x = max(max_x, max_x_)
if len(s.y_data) > 0:
min_y_ = min(s.y_data)
if min_y is None:
min_y = min_y_
else:
min_y = min(min_y, min_y_)
max_y_ = max(s.y_data)
if max_y is None:
max_y = max_y_
else:
max_y = max(max_y, max_y_)
if min_x and max_x - TIME_WINDOW > min_x:
for s in series.values():
s.clip(max_x - TIME_WINDOW)
finally:
series_lock.release()
if min_x != max_x:
ax.set_xlim(min_x, max_x)
if min_y != max_y:
margin_y = 0.01 * (max_y - min_y)
ax.set_ylim(min_y - margin_y, max_y + margin_y)
global legend
legend.remove()
legend = ax.legend()
ani = animation.FuncAnimation(
fig, animate, interval=1, blit=False, save_count=50)
plt.show()
quit = True
thread.join()

View File

@ -3,17 +3,16 @@ requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "pythermostat"
name = "pytec"
version = "0.0"
authors = [{name = "M-Labs"}]
description = "Control TEC"
urls.Repository = "https://git.m-labs.hk/M-Labs/thermostat"
license = {text = "GPLv3"}
[project.scripts]
thermostat_autotune = "pythermostat.autotune:main"
thermostat_test = "pythermostat.test:main"
[project.gui-scripts]
thermostat_plot = "pythermostat.plot:main"
thermostat_qt = "pythermostat.thermostat_qt:main"
tec_qt = "tec_qt:main"
[tool.setuptools]
packages.find = {}
py-modules = ["autotune", "plot", "tec_qt"]

View File

@ -1,10 +1,10 @@
from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot
from qasync import asyncSlot
from pytec.gui.model.property import Property, PropertyMeta
import asyncio
import logging
from enum import Enum
from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot
from qasync import asyncSlot
from pythermostat.aioclient import AsyncioClient
from pythermostat.gui.model.property import Property, PropertyMeta
from pytec.aioclient import AsyncioClient
class ThermostatConnectionState(Enum):

View File

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

View File

@ -1,6 +1,6 @@
from PyQt6 import QtWidgets, QtCore
from PyQt6.QtCore import pyqtSlot
from pythermostat.gui.model.thermostat import ThermostatConnectionState
from pytec.gui.model.thermostat import ThermostatConnectionState
class ConnectionDetailsMenu(QtWidgets.QMenu):

View File

@ -5,7 +5,7 @@ from pglive.sources.live_plot import LiveLinePlot
from pglive.sources.live_axis import LiveAxis
from collections import deque
import pyqtgraph as pg
from pythermostat.gui.model.thermostat import ThermostatConnectionState
from pytec.gui.model.thermostat import ThermostatConnectionState
pg.setConfigOptions(antialias=True)

View File

@ -563,7 +563,7 @@
<customwidget>
<class>QtWaitingSpinner</class>
<extends>QWidget</extends>
<header>pythermostat.gui.view.waitingspinnerwidget</header>
<header>pytec.gui.view.waitingspinnerwidget</header>
<container>1</container>
</customwidget>
</customwidgets>

View File

@ -2,8 +2,8 @@ import logging
from PyQt6 import QtWidgets, QtGui, QtCore
from PyQt6.QtCore import pyqtSignal, pyqtSlot, QSignalBlocker
from qasync import asyncSlot
from pythermostat.gui.view.net_settings_input_diag import NetSettingsInputDiag
from pythermostat.gui.model.thermostat import ThermostatConnectionState
from pytec.gui.view.net_settings_input_diag import NetSettingsInputDiag
from pytec.gui.model.thermostat import ThermostatConnectionState
class ThermostatSettingsMenu(QtWidgets.QMenu):

18
pytec/setup.py Normal file
View File

@ -0,0 +1,18 @@
from setuptools import setup, find_packages
setup(
name="pytec",
version="0.0",
author="M-Labs",
url="https://git.m-labs.hk/M-Labs/thermostat",
description="Control TEC",
license="GPLv3",
install_requires=["setuptools"],
packages=find_packages(),
entry_points={
"gui_scripts": [
"tec_qt = tec_qt:main",
]
},
py_modules=['autotune', 'plot', 'tec_qt'],
)

View File

@ -9,16 +9,16 @@ from PyQt6 import QtWidgets, QtGui, uic
from PyQt6.QtCore import pyqtSlot
import qasync
from qasync import asyncSlot, asyncClose
from pythermostat.autotune import PIDAutotuneState
from pythermostat.gui.model.thermostat import Thermostat, ThermostatConnectionState
from pythermostat.gui.model.pid_autotuner import PIDAutoTuner
from pythermostat.gui.view.connection_details_menu import ConnectionDetailsMenu
from pythermostat.gui.view.ctrl_panel import CtrlPanel
from pythermostat.gui.view.info_box import InfoBox
from pythermostat.gui.view.live_plot_view import LiveDataPlotter
from pythermostat.gui.view.plot_options_menu import PlotOptionsMenu
from pythermostat.gui.view.thermostat_settings_menu import ThermostatSettingsMenu
from pythermostat.gui.view.zero_limits_warning_view import ZeroLimitsWarningView
from autotune import PIDAutotuneState
from pytec.gui.model.thermostat import Thermostat, ThermostatConnectionState
from pytec.gui.model.pid_autotuner import PIDAutoTuner
from pytec.gui.view.zero_limits_warning_view import ZeroLimitsWarningView
from pytec.gui.view.thermostat_settings_menu import ThermostatSettingsMenu
from pytec.gui.view.connection_details_menu import ConnectionDetailsMenu
from pytec.gui.view.plot_options_menu import PlotOptionsMenu
from pytec.gui.view.live_plot_view import LiveDataPlotter
from pytec.gui.view.ctrl_panel import CtrlPanel
from pytec.gui.view.info_box import InfoBox
def get_argparser():
@ -42,7 +42,7 @@ def get_argparser():
parser.add_argument(
"-p",
"--param_tree",
default=importlib.resources.files("pythermostat.gui.view").joinpath("param_tree.json"),
default=importlib.resources.files("pytec.gui.view").joinpath("param_tree.json"),
help="Param Tree Description JSON File",
)
@ -55,7 +55,7 @@ class MainWindow(QtWidgets.QMainWindow):
def __init__(self, args):
super().__init__()
ui_file_path = importlib.resources.files("pythermostat.gui.view").joinpath("tec_qt.ui")
ui_file_path = importlib.resources.files("pytec.gui.view").joinpath("tec_qt.ui")
uic.loadUi(ui_file_path, self)
self._info_box = InfoBox()
@ -229,7 +229,7 @@ async def coro_main():
app.aboutToQuit.connect(app_quit_event.set)
app.setWindowIcon(
QtGui.QIcon(
str(importlib.resources.files("pythermostat.gui.resources").joinpath("artiq.ico"))
str(importlib.resources.files("pytec.gui.resources").joinpath("artiq.ico"))
)
)

View File

@ -1,6 +1,6 @@
import argparse
from contextlib import contextmanager
from pythermostat.client import Client
from pytec.client import Client
CHANNELS = 2

View File

@ -1,4 +0,0 @@
graft examples
include pythermostat/gui/resources/artiq.ico
include pythermostat/gui/view/param_tree.json
include pythermostat/gui/view/tec_qt.ui

View File

@ -1,137 +0,0 @@
import time
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from threading import Thread, Lock
from pythermostat.client import Client
def main():
TIME_WINDOW = 300.0
tec = Client()
target_temperature = tec.get_pid()[0]['target']
print("Channel 0 target temperature: {:.3f}".format(target_temperature))
class Series:
def __init__(self, conv=lambda x: x):
self.conv = conv
self.x_data = []
self.y_data = []
def append(self, x, y):
self.x_data.append(x)
self.y_data.append(self.conv(y))
def clip(self, min_x):
drop = 0
while drop < len(self.x_data) and self.x_data[drop] < min_x:
drop += 1
self.x_data = self.x_data[drop:]
self.y_data = self.y_data[drop:]
series = {
# 'adc': Series(),
# 'sens': Series(lambda x: x * 0.0001),
'temperature': Series(),
# 'i_set': Series(),
'pid_output': Series(),
# 'vref': Series(),
# 'dac_value': Series(),
# 'dac_feedback': Series(),
# 'i_tec': Series(),
'tec_i': Series(),
'tec_u_meas': Series(),
# 'interval': Series(),
}
series_lock = Lock()
quit = False
def recv_data(tec):
global last_packet_time
while True:
data = tec.get_report()
ch0 = data[0]
series_lock.acquire()
try:
for k, s in series.items():
if k in ch0:
v = ch0[k]
if type(v) is float:
s.append(ch0['time'], v)
finally:
series_lock.release()
if quit:
break
time.sleep(0.05)
thread = Thread(target=recv_data, args=(tec,))
thread.start()
fig, ax = plt.subplots()
for k, s in series.items():
s.plot, = ax.plot([], [], label=k)
legend = ax.legend()
def animate(i):
min_x, max_x, min_y, max_y = None, None, None, None
series_lock.acquire()
try:
for k, s in series.items():
s.plot.set_data(s.x_data, s.y_data)
if len(s.y_data) > 0:
s.plot.set_label("{}: {:.3f}".format(k, s.y_data[-1]))
if len(s.x_data) > 0:
min_x_ = min(s.x_data)
if min_x is None:
min_x = min_x_
else:
min_x = min(min_x, min_x_)
max_x_ = max(s.x_data)
if max_x is None:
max_x = max_x_
else:
max_x = max(max_x, max_x_)
if len(s.y_data) > 0:
min_y_ = min(s.y_data)
if min_y is None:
min_y = min_y_
else:
min_y = min(min_y, min_y_)
max_y_ = max(s.y_data)
if max_y is None:
max_y = max_y_
else:
max_y = max(max_y, max_y_)
if min_x and max_x - TIME_WINDOW > min_x:
for s in series.values():
s.clip(max_x - TIME_WINDOW)
finally:
series_lock.release()
if min_x != max_x:
ax.set_xlim(min_x, max_x)
if min_y != max_y:
margin_y = 0.01 * (max_y - min_y)
ax.set_ylim(min_y - margin_y, max_y + margin_y)
nonlocal legend
legend.remove()
legend = ax.legend()
ani = animation.FuncAnimation(
fig, animate, interval=1, blit=False, save_count=50)
plt.show()
quit = True
thread.join()
if __name__ == "__main__":
main()