Compare commits

...

16 Commits

Author SHA1 Message Date
8db4867ebf PyThermostat: Improve pyproject metadata 2024-11-25 13:38:04 +08:00
130bde480e PyThermostat: Replace setup.py with pyproject.toml 2024-11-25 13:14:54 +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
8dd58b364d README: Fix limits section 2024-11-18 14:01:51 +08:00
ae0d593139 pytec: Stop using client report mode in plot.py
Report mode has been removed from the client, stop using it.
2024-11-18 13:57:54 +08:00
adc25c9b2a pytec: Add hardware testing script
Eases the process of testing the hardware.

See #143.
2024-11-18 10:31:56 +08:00
9af86be674 pytec: Remove artificial report mode in client
Encourage polling usage instead, as shown in example.
2024-11-16 13:11:59 +08:00
eabc7f6a12 flake: Register the pytec Python package 2024-11-11 17:11:37 +08:00
52e35d2a98 flake: Format with nixfmt-rfc-style
Also set up formatter so that `nix fmt` formats.
2024-11-04 18:38:08 +08:00
f1da910c11 pytec: Complete client
Expose all available Thermostat commands to the pytec client, and add
disconnect functionality.
2024-11-04 18:03:50 +08:00
12 changed files with 369 additions and 186 deletions

View File

@ -189,31 +189,30 @@ Testing heat flow direction with a low set current is recommended before install
### Limits ### Limits
Each MAX1968 TEC driver has analog/PWM inputs for setting Each channel has maximum value settings, for setting
output limits. output limits.
Use the `output` command to see current settings and maximum values. Use the `output` command to see them.
| Limit | Unit | Description | | Limit | Unit | Description |
| --- | :---: | --- | | --- | :---: | --- |
| `max_v` | Volts | Maximum voltage | | `max_v` | Volts | Maximum voltage |
| `max_i_pos` | Amperes | Maximum positive current | | `max_i_pos` | Amperes | Maximum positive current |
| `max_i_neg` | Amperes | Maximum negative current | | `max_i_neg` | Amperes | Maximum negative current |
| `i_set` | Amperes | (Not a limit; Open-loop mode) |
Example: set the maximum voltage of channel 0 to 1.5 V. Example: set the maximum voltage of channel 0 to 1.5 V.
``` ```
output 0 max_v 1.5 output 0 max_v 1.5
``` ```
Example: set the maximum negative current of channel 0 to -3 A. Example: set the maximum negative current of channel 0 to -2 A.
``` ```
output 0 max_i_neg 3 output 0 max_i_neg 2
``` ```
Example: set the maximum positive current of channel 1 to 3 A. Example: set the maximum positive current of channel 1 to 2 A.
``` ```
output 0 max_i_pos 3 output 1 max_i_pos 2
``` ```
### Open-loop mode ### Open-loop mode
@ -240,6 +239,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

@ -2,14 +2,22 @@
description = "Firmware for the Sinara 8451 Thermostat"; description = "Firmware for the Sinara 8451 Thermostat";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
inputs.rust-overlay = { inputs.rust-overlay = {
url = "github:oxalica/rust-overlay"; url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
outputs = { self, nixpkgs, rust-overlay }: outputs =
{
self,
nixpkgs,
rust-overlay,
}:
let let
pkgs = import nixpkgs { system = "x86_64-linux"; overlays = [ (import rust-overlay) ]; }; pkgs = import nixpkgs {
system = "x86_64-linux";
overlays = [ (import rust-overlay) ];
};
rust = pkgs.rust-bin.stable."1.66.0".default.override { rust = pkgs.rust-bin.stable."1.66.0".default.override {
extensions = [ "rust-src" ]; extensions = [ "rust-src" ];
@ -25,7 +33,7 @@
version = "0.0.0"; version = "0.0.0";
src = self; src = self;
cargoLock = { cargoLock = {
lockFile = ./Cargo.lock; lockFile = ./Cargo.lock;
outputHashes = { outputHashes = {
"stm32-eth-0.2.0" = "sha256-48RpZgagUqgVeKm7GXdk3Oo0v19ScF9Uby0nTFlve2o="; "stm32-eth-0.2.0" = "sha256-48RpZgagUqgVeKm7GXdk3Oo0v19ScF9Uby0nTFlve2o=";
@ -49,9 +57,23 @@
dontFixup = true; dontFixup = true;
auditable = false; auditable = false;
}; };
in {
pythermostat = pkgs.python3Packages.buildPythonPackage {
pname = "pythermostat";
version = "0.0.0";
format = "pyproject";
src = "${self}/pythermostat";
propagatedBuildInputs =
with pkgs.python3Packages; [
numpy
matplotlib
];
};
in
{
packages.x86_64-linux = { packages.x86_64-linux = {
inherit thermostat; inherit thermostat pythermostat;
default = thermostat; default = thermostat;
}; };
@ -61,12 +83,21 @@
devShells.x86_64-linux.default = pkgs.mkShellNoCC { devShells.x86_64-linux.default = pkgs.mkShellNoCC {
name = "thermostat-dev-shell"; name = "thermostat-dev-shell";
packages = with pkgs; [ packages =
rust llvm with pkgs;
openocd dfu-util rlwrap [
] ++ (with python3Packages; [ rust
numpy matplotlib llvm
openocd
dfu-util
rlwrap
]
++ (with python3Packages; [
numpy
matplotlib
]); ]);
}; };
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt-rfc-style;
}; };
} }

View File

@ -1,128 +0,0 @@
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
for data in tec.report_mode():
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
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,12 +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(),
)

View File

@ -1,4 +1,5 @@
from pytec.client import Client import time
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)
@ -7,5 +8,6 @@ print(tec.get_pid())
print(tec.get_output()) print(tec.get_output())
print(tec.get_postfilter()) print(tec.get_postfilter())
print(tec.get_b_parameter()) print(tec.get_b_parameter())
for data in tec.report_mode(): while True:
print(data) print(tec.get_report())
time.sleep(0.05)

View File

@ -0,0 +1,18 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "pythermostat"
version = "0.0"
authors = [{name = "M-Labs"}]
description = "Python utilities for the Sinara 8451 Thermostat"
urls.Repository = "https://git.m-labs.hk/M-Labs/thermostat"
license = {text = "GPLv3"}
[project.gui-scripts]
thermostat_plot = "pythermostat.plot:main"
[project.scripts]
thermostat_autotune = "pythermostat.autotune:main"
thermostat_test = "pythermostat.test: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
@ -236,13 +237,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]
@ -255,6 +257,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,7 +1,7 @@
import socket import socket
import json import json
import logging import logging
import time
class CommandError(Exception): class CommandError(Exception):
pass pass
@ -12,6 +12,10 @@ class Client:
self._lines = [""] self._lines = [""]
self._check_zero_limits() self._check_zero_limits()
def disconnect(self):
self._socket.shutdown(socket.SHUT_RDWR)
self._socket.close()
def _check_zero_limits(self): def _check_zero_limits(self):
output_report = self.get_output() output_report = self.get_output()
for output_channel in output_report: for output_channel in output_report:
@ -110,18 +114,18 @@ class Client:
""" """
return self._get_conf("postfilter") return self._get_conf("postfilter")
def report_mode(self): def get_report(self):
"""Start reporting measurement values """Obtain one-time report on measurement values
Example of yielded data:: Example of yielded data::
{'channel': 0, {'channel': 0,
'time': 2302524, 'time': 2302524,
'interval': 0.12
'adc': 0.6199188965423515, 'adc': 0.6199188965423515,
'sens': 6138.519310282602, 'sens': 6138.519310282602,
'temperature': 36.87032392655527, 'temperature': 36.87032392655527,
'pid_engaged': True, 'pid_engaged': True,
'i_set': 2.0635816680889123, 'i_set': 2.0635816680889123,
'vref': 1.494,
'dac_value': 2.527790834044456, 'dac_value': 2.527790834044456,
'dac_feedback': 2.523, 'dac_feedback': 2.523,
'i_tec': 2.331, 'i_tec': 2.331,
@ -129,16 +133,19 @@ class Client:
'tec_u_meas': 2.5340000000000003, 'tec_u_meas': 2.5340000000000003,
'pid_output': 2.067581958092247} 'pid_output': 2.067581958092247}
""" """
while True: return self._get_conf("report")
self._socket.sendall("report\n".encode('utf-8'))
line = self._read_line() def get_ipv4(self):
if not line: """Get the IPv4 settings of the Thermostat"""
break return self._command("ipv4")
try:
yield json.loads(line) def get_fan(self):
except json.decoder.JSONDecodeError: """Get Thermostat current fan settings"""
pass return self._command("fan")
time.sleep(0.05)
def get_hwrev(self):
"""Get Thermostat hardware revision"""
return self._command("hwrev")
def set_param(self, topic, channel, field="", value=""): def set_param(self, topic, channel, field="", value=""):
"""Set configuration parameters """Set configuration parameters
@ -163,10 +170,38 @@ class Client:
self.set_param("pid", channel, "target", value=target) self.set_param("pid", channel, "target", value=target)
self.set_param("output", channel, "pid") self.set_param("output", channel, "pid")
def save_config(self): def save_config(self, channel=""):
"""Save current configuration to EEPROM""" """Save current configuration to EEPROM"""
self._command("save") self._command("save", channel)
if channel != "":
self._read_line() # read the extra {}
def load_config(self): def load_config(self, channel=""):
"""Load current configuration from EEPROM""" """Load current configuration from EEPROM"""
self._command("load") self._command("load", channel)
if channel != "":
self._read_line() # read the extra {}
def reset(self):
"""Reset the device"""
self._socket.sendall("reset".encode("utf-8"))
self.disconnect() # resetting ends the TCP session, disconnect anyway
def enter_dfu_mode(self):
"""Reset device and enters USB device firmware update (DFU) mode"""
self._socket.sendall("dfu".encode("utf-8"))
self.disconnect() # resetting ends the TCP session, disconnect anyway
def set_ipv4(self, address, netmask, gateway=""):
"""Configure IPv4 address, netmask length, and optional default gateway"""
self._command("ipv4", f"{address}/{netmask}", gateway)
def set_fan(self, power=None):
"""Set fan power with values from 1 to 100. If omitted, set according to fcurve"""
if power is None:
power = "auto"
self._command("fan", power)
def set_fcurve(self, a=1.0, b=0.0, c=0.0):
"""Set fan controller curve coefficients"""
self._command("fcurve", a, b, c)

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

@ -0,0 +1,81 @@
import argparse
from contextlib import contextmanager
from pythermostat.client import Client
CHANNELS = 2
def get_argparser():
parser = argparse.ArgumentParser(description="Thermostat hardware testing script")
parser.add_argument("host", metavar="HOST", default="192.168.1.26", nargs="?")
parser.add_argument("port", metavar="PORT", default=23, nargs="?")
parser.add_argument(
"-r",
"--testing_resistance",
default=10_000,
help="Testing resistance value through SENS pin in Ohms",
)
parser.add_argument(
"-d",
"--deviation",
default=1,
help="Allowed deviation of resistance in percentage",
)
return parser
def main():
args = get_argparser().parse_args()
min_allowed_resistance = args.testing_resistance * (1 - args.deviation / 100)
max_allowed_resistance = args.testing_resistance * (1 + args.deviation / 100)
print(min_allowed_resistance, max_allowed_resistance)
thermostat = Client(args.host, args.port)
for channel in range(CHANNELS):
print(f"Channel {channel} is active")
print("Checking resistance through SENS input ....", end=" ")
sens_resistance = thermostat.get_report()[channel]["sens"]
if sens_resistance is not None:
print(sens_resistance, "Ω")
if min_allowed_resistance <= sens_resistance <= max_allowed_resistance:
print("PASSED")
else:
print("FAILED")
else:
print("Floating SENS input! Is the channel connected?")
with preserve_thermostat_output_settings(thermostat, channel):
test_output_settings = {
"max_i_pos": 2,
"max_i_neg": 2,
"max_v": 4,
"i_set": 0.1,
"polarity": "normal",
}
for field, value in test_output_settings.items():
thermostat.set_param("output", channel, field, value)
input(f"Check if channel {channel} current = 0.1 A, and press ENTER...")
input(f"Channel {channel} testing done, press ENTER to continue.")
print()
print("Testing complete.")
@contextmanager
def preserve_thermostat_output_settings(client, channel):
original_output_settings = client.get_output()[channel]
yield original_output_settings
for setting in "max_i_pos", "max_i_neg", "max_v", "i_set", "polarity":
client.set_param("output", channel, setting, original_output_settings[setting])
if __name__ == "__main__":
main()