Compare commits

..

18 Commits

Author SHA1 Message Date
1eba7d8556 README: Introduce Thermostat GUI
Co-authored-by: topquark12 <aw@m-labs.hk>
2024-11-25 13:10:26 +08:00
a0bc119ffb PyThermostat: Use pyproject 2024-11-25 13:10:26 +08:00
bfbe8b79a7 PyThermostat GUI: Set up packaging
Co-authored-by: Egor Savkin <es@m-labs.hk>
2024-11-25 12:31:44 +08:00
f14e67de82 PyThermostat GUI: Implement Control Panel
Co-authored-by: linuswck <linuswck@m-labs.hk>
Co-authored-by: Egor Savkin <es@m-labs.hk>
2024-11-25 12:24:05 +08:00
0a99e29d4d PyThermostat GUI: Implement PlotSettingsMenu
Co-authored-by: linuswck <linuswck@m-labs.hk>
2024-11-25 12:24:05 +08:00
675ce9f0c7 PyThermostat GUI: Implement plotting
Co-authored-by: linuswck <linuswck@m-labs.hk>
2024-11-25 12:24:05 +08:00
e7d9126967 PyThermostat 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-25 12:24:05 +08:00
8589ebdf0f PyThermostat GUI: Implement ThermostatSettingsMenu
Co-authored-by: linuswck <linuswck@m-labs.hk>
Co-authored-by: Egor Savkin <es@m-labs.hk>
2024-11-25 12:24:05 +08:00
6e9b1cfe21 PyThermostat GUI: Implement status line
Co-authored-by: linuswck <linuswck@m-labs.hk>
Co-authored-by: Egor Savkin <es@m-labs.hk>
2024-11-25 12:24:05 +08:00
ca4e43b0d9 PyThermostat: 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-25 12:24:02 +08:00
72c1aab682 PyThermostat: Create asyncio clients 2024-11-25 11:26:44 +08:00
36d80ebdff PyThermostat: Add entry points for runnables
Forms a more convienient interface.
2024-11-25 10:07:30 +08:00
09300b5d44 PyThermostat: Add main function to plot.py 2024-11-25 10:07:30 +08:00
9743dca775 PyThermostat: Move scripts into subfolder
As Thermostat Python scripts are not single-file Python modules and
should be packaged inside PyThermostat.
2024-11-25 10:07:20 +08:00
11131deda2 README: Add PID Output Clamping section
Explains the need of having separate "max_i_pos/output_max" and
"max_i_neg/output_min" values; They serve different purposes.
2024-11-20 08:02:07 +08:00
764774fbce PyThermostat: Remove report mode in autotune.py 2024-11-18 17:47:33 +08:00
4beeec6021 PyThermostat: Remove all references to Pytec 2024-11-18 17:34:39 +08:00
6b8a5f5bb8 Rename the Pytec library to PyThermostat
Pytec is a misnomer, as the Thermostat is not limited to just
controlling TEC modules. The library also interfaces with and controls
the Thermostat itself, and not the TEC module directly.

See M-Labs/thermostat#149 (comment)
2024-11-18 16:22:57 +08:00
32 changed files with 205 additions and 196 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. A GUI has been developed for easy configuration and plotting of key parameters.
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 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
launched automatically by running: launched automatically by running:
``` ```
@ -250,6 +250,22 @@ of channel 0 to the PID algorithm:
output 0 pid 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 ## LED indicators
| Name | Color | Meaning | | 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 To use the Python real-time plotting utility, run
```shell ```shell
python pytec/plot.py python pythermostat/pythermostat/plot.py
``` ```
![default view](./assets/default%20view.png) ![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 ## Auto Tuning
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. 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.
To run the auto tuning utility, run To run the auto tuning utility, run
```shell ```shell
python pytec/autotune.py python pythermostat/pythermostat/autotune.py
``` ```
After some time, the auto tuning utility will output the auto tuning results, below is a sample output 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; auditable = false;
}; };
pytec = pkgs.python3Packages.buildPythonPackage { pythermostat = pkgs.python3Packages.buildPythonPackage {
pname = "pytec"; pname = "pythermostat";
version = "0.0.0"; version = "0.0.0";
format = "pyproject"; format = "pyproject";
src = "${self}/pytec"; src = "${self}/pythermostat";
nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ]; nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ];
propagatedBuildInputs = propagatedBuildInputs =
@ -99,13 +99,13 @@
in in
{ {
packages.x86_64-linux = { packages.x86_64-linux = {
inherit thermostat pytec; inherit thermostat pythermostat;
default = thermostat; default = thermostat;
}; };
apps.x86_64-linux.thermostat_gui = { apps.x86_64-linux.thermostat_gui = {
type = "app"; type = "app";
program = "${self.packages.x86_64-linux.pytec}/bin/tec_qt"; program = "${self.packages.x86_64-linux.pythermostat}/bin/tec_qt";
}; };
hydraJobs = { hydraJobs = {

View File

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

@ -1,18 +0,0 @@
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'],
)

4
pythermostat/MANIFEST.in Normal file
View File

@ -0,0 +1,4 @@
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,6 +1,6 @@
import asyncio import asyncio
from contextlib import suppress from contextlib import suppress
from pytec.aioclient import AsyncioClient from pythermostat.aioclient import AsyncioClient
async def poll_for_info(tec): async def poll_for_info(tec):

View File

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

View File

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

View File

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

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 asyncio
import logging import logging
from enum import Enum from enum import Enum
from pytec.aioclient import AsyncioClient from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot
from qasync import asyncSlot
from pythermostat.aioclient import AsyncioClient
from pythermostat.gui.model.property import Property, PropertyMeta
class ThermostatConnectionState(Enum): 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 import QtWidgets, QtCore
from PyQt6.QtCore import pyqtSlot from PyQt6.QtCore import pyqtSlot
from pytec.gui.model.thermostat import ThermostatConnectionState from pythermostat.gui.model.thermostat import ThermostatConnectionState
class ConnectionDetailsMenu(QtWidgets.QMenu): 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 pglive.sources.live_axis import LiveAxis
from collections import deque from collections import deque
import pyqtgraph as pg import pyqtgraph as pg
from pytec.gui.model.thermostat import ThermostatConnectionState from pythermostat.gui.model.thermostat import ThermostatConnectionState
pg.setConfigOptions(antialias=True) pg.setConfigOptions(antialias=True)

View File

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

View File

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

View File

@ -0,0 +1,137 @@
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()

View File

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

View File

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