Compare commits

..

66 Commits

Author SHA1 Message Date
d7bacdecd7 AsyncIO version Client -> AsyncioClient 2024-07-08 17:21:58 +08:00
070dc28f9d Dedup show mainwindow call 2024-07-08 13:22:53 +08:00
c89f1eab17 flake.nix: nixfmt-rfc-style 2024-07-08 12:34:33 +08:00
12a1dd4034 Add back the parent 2024-07-08 12:34:13 +08:00
96210151d5 fixup! thermostat part 2 2024-07-08 12:34:02 +08:00
684d123d6f Remove duplicated antialias option 2024-07-08 12:33:45 +08:00
3075a19c95 thermostat part 2 2024-07-08 12:13:36 +08:00
2c019b8208 Use thermotat_data_model for everything 2024-07-08 12:03:36 +08:00
e129998629 Use Thermostat data model directly 2024-07-08 11:55:09 +08:00
7e15ffd43c disconnect -> end_session
QObject already has a disconnect method
2024-07-08 11:54:44 +08:00
db71c6fd4f fixup! Use thermostat data model 2024-07-08 11:20:08 +08:00
581ce61578 Use asserts to check for connectivity 2024-07-08 11:18:02 +08:00
a8121984e2 More elegant exception rethrow 2024-07-08 11:17:44 +08:00
b83cef24c7 Use thermostat data model 2024-07-08 11:15:42 +08:00
54bedc3a83 Timeout Error things 2024-07-05 17:31:55 +08:00
c1fdcda621 Integrate WrappedClient into Thermostat model 2024-07-05 17:21:39 +08:00
37c982b786 Config -> Settings part 2 2024-07-05 11:58:36 +08:00
98db321c2c Should not stop cancelling read if timeout'd 2024-07-04 17:30:49 +08:00
d29fae0476 Remove exception too general 2024-07-04 17:30:28 +08:00
0791f0df4b fixup! Try fix force-disconnections when autotuning 2024-07-04 17:28:23 +08:00
e8930a4b7e Use connection lost nomenclature 2024-07-04 11:51:57 +08:00
760c1461e9 Formatting 2024-07-04 11:51:57 +08:00
8b1e62962f Format JSON 2024-07-03 13:40:50 +08:00
9617d64c56 grammar 2024-06-28 13:18:41 +08:00
4b15bff0e5 PID Auto Tune -> PID Autotune 2024-06-28 12:59:54 +08:00
50e88b9371 Stop crushing spinbox in ctrl_panel
It might work on some themes, but on the default Qt theme the spinbox
are slim. See https://github.com/pyqtgraph/pyqtgraph/issues/701.
2024-06-28 12:59:54 +08:00
2952df46ac Use siPrefix for displaying measured current
Won't have the unit adjustment problem
2024-06-28 12:59:54 +08:00
e6edfea81d Pin down units in ctrl_panel
Fix units to something reasonable in fields
2024-06-28 12:59:54 +08:00
f6f8b191a0 flake.nix improvements & dedeprecate 2024-06-28 12:59:54 +08:00
df85df0c85 Swap order arounda bit more 2024-06-28 12:59:54 +08:00
7c5bd633cc Try fix force-disconnections when autotuning 2024-06-28 12:59:54 +08:00
4bc7d9ce45 Better tooltip on PID Autotune button 2024-06-28 12:59:54 +08:00
4fb1043b9e Use titles for paramtee entries
For conciseness and easier changing of displayed parameter names.
2024-06-28 12:59:53 +08:00
dc8e682ac6 Config -> Settings 2024-06-28 12:59:53 +08:00
cca0e3c746 Remove setup.py 2024-06-28 12:59:53 +08:00
16b1411e4b GUI folder further inwards 2024-06-28 12:59:53 +08:00
814e714477 Use MANIFEST.in 2024-06-28 12:59:53 +08:00
60b81e7142 Move examples into folder 2024-06-28 12:59:53 +08:00
8021faa00d Move gui components into folder 2024-06-28 12:59:53 +08:00
82fb0b0ec6 pyproject.toml fixes 2024-06-28 12:59:53 +08:00
691269cbdc README: Proofread 2024-06-28 12:59:53 +08:00
bdbb7f9b78 Use qtextras 2024-06-28 12:59:53 +08:00
872b7e02f3 Make interrupted connection handling more elegant
* Show a disconnected info box informing the user that the device was
  forcefully disconnected and requires user intervention.

* Don't print exception info to console on connection failure to avoid
  cluttering it up with programmer info.
2024-06-28 12:59:53 +08:00
c978a0dda6 thermostat_data_model -> thermostat 2024-06-28 12:59:53 +08:00
1d2497d734 Class names should be CamelCase 2024-06-28 12:59:53 +08:00
b6692b55a4 Add tooltips in parameter tree 2024-06-28 12:59:53 +08:00
1340057449 flake: sha256 -> hash 2024-06-28 12:59:53 +08:00
b353916188 Put comments in right place 2024-06-28 12:59:53 +08:00
726f1a3657 Restructure GUI Code, Improve and Fix Bugs
- Bugs fix:
1. Params Tree user input will not get overwritten
    by incoming report thermostat_data_model.
2. PID Autotune Sampling Period is now set according to Thermostat sampling interval
3. PID Autotune won't get stuck in Fail State
4. Various types disconnection related Bugs
5. Number of Samples stored in the plot cannot be set
6. Limit the max settable output current to be 2000mA

- Improvement:
1. Params Tree settings can be changed with external json
2. Use a Tab system to show a single channel of config instead of two
3. Expose PID Autotune lookback params
4. Icon is changed to Artiq logo

- Restructure:
1. Restructure the code to follow Model-View-Delegate Design Pattern
2024-06-28 12:59:53 +08:00
8445d3cf3d Finish GUI 2024-06-28 12:59:53 +08:00
c338fbde98 Remove unused as clause 2024-06-28 12:59:53 +08:00
0cc3d8e979 Add paramtree view, without updates
Signed-off-by: Egor Savkin <es@m-labs.hk>

Fix signal blocker argument -atse
2024-06-28 12:59:53 +08:00
1672c72a5f Fix bugs, grammar, text, and refactor into class 2024-06-28 12:59:53 +08:00
81e3c12b1c Change title 2024-06-28 12:59:53 +08:00
dd807cfddc Stop polling drift
Just waiting for the update_s doesn't take into account the time to
execute update_params, and causes time drift.
2024-06-28 12:59:53 +08:00
7868d58569 Remove unused 'as' clause 2024-06-28 12:59:53 +08:00
542bf15e77 Update docs 2024-06-28 12:59:53 +08:00
aeb3c9324d Finish moving over to qasync
Also:

* Add aioclient

The old client is synchronous and blocking, and the only way to achieve
true asynchronous IO is to create a new client that interfaces with
asyncio.

* Finish Nix Flake description and make the GUI available for `nix run`
2024-06-28 12:59:53 +08:00
0244dec5be Try move from Qthreads to qasync
Signed-off-by: Egor Savkin <es@m-labs.hk>
2024-06-28 12:59:53 +08:00
bfb696c1ce Create client watcher, that would poll Thermostat for config
Signed-off-by: Egor Savkin <es@m-labs.hk>
2024-06-28 12:59:53 +08:00
3a72ddc899 Create basic GUI, that would connect and control thermostat's fan
Signed-off-by: Egor Savkin <es@m-labs.hk>
2024-06-28 12:59:53 +08:00
6fe2cfba38 add autotune 2024-06-28 12:59:53 +08:00
44e9130010 Use oxalica's rust-overlay
Follow ARTIQ, and in this project lets us include the version number
directly in flake.nix instead of linking to the toml file of a specific
release date, as we use stable Rust.

Also, from nixpkgs manual:
    both oxalica's overlay and fenix better integrate with nix and cache
    optimizations. Because of this and ergonomics, either of those
    community projects should be preferred to the Mozilla's Rust overlay
    (nixpkgs-mozilla).
2024-06-27 12:42:00 +08:00
5b0c6f7018 Save i_set into ChannelConfig 2024-05-18 10:50:54 +08:00
1007982b48 clamp TEC settings to a valid & design specs range
- Not respecting the design specs can cause hardware to get stuck in unrecoverable state
2024-05-10 15:17:46 +08:00
925601f4f5 rm pid setpoint change kick 2024-05-10 10:29:08 +08:00
21 changed files with 928 additions and 863 deletions

46
flake.lock generated
View File

@ -1,28 +1,12 @@
{
"nodes": {
"mozilla-overlay": {
"flake": false,
"locked": {
"lastModified": 1704373101,
"narHash": "sha256-+gi59LRWRQmwROrmE1E2b3mtocwueCQqZ60CwLG+gbg=",
"owner": "mozilla",
"repo": "nixpkgs-mozilla",
"rev": "9b11a87c0cc54e308fa83aac5b4ee1816d5418a2",
"type": "github"
},
"original": {
"owner": "mozilla",
"repo": "nixpkgs-mozilla",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1704290814,
"narHash": "sha256-LWvKHp7kGxk/GEtlrGYV68qIvPHkU9iToomNFGagixU=",
"lastModified": 1691421349,
"narHash": "sha256-RRJyX0CUrs4uW4gMhd/X4rcDG8PTgaaCQM5rXEJOx6g=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "70bdadeb94ffc8806c0570eb5c2695ad29f0e421",
"rev": "011567f35433879aae5024fc6ec53f2a0568a6c4",
"type": "github"
},
"original": {
@ -34,8 +18,28 @@
},
"root": {
"inputs": {
"mozilla-overlay": "mozilla-overlay",
"nixpkgs": "nixpkgs"
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1719281921,
"narHash": "sha256-LIBMfhM9pMOlEvBI757GOK5l0R58SRi6YpwfYMbf4yc=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "b6032d3a404d8a52ecfc8571ff0c26dfbe221d07",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},

View File

@ -2,36 +2,32 @@
description = "Firmware for the Sinara 8451 Thermostat";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.05";
inputs.mozilla-overlay = {
url = "github:mozilla/nixpkgs-mozilla";
flake = false;
inputs.rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, mozilla-overlay, }:
outputs =
{
self,
nixpkgs,
rust-overlay,
}:
let
pkgs = import nixpkgs {
system = "x86_64-linux";
overlays = [ (import mozilla-overlay) ];
};
rustManifest = pkgs.fetchurl {
url =
"https://static.rust-lang.org/dist/2022-12-15/channel-rust-stable.toml";
hash = "sha256-S7epLlflwt0d1GZP44u5Xosgf6dRrmr8xxC+Ml2Pq7c=";
overlays = [ (import rust-overlay) ];
};
targets = [ "thumbv7em-none-eabihf" ];
rustChannelOfTargets = _channel: _date: targets:
(pkgs.lib.rustLib.fromManifestFile rustManifest {
inherit (pkgs) stdenv lib fetchurl patchelf;
}).rust.override {
inherit targets;
extensions = [ "rust-src" ];
};
rust = rustChannelOfTargets "stable" null targets;
rustPlatform = pkgs.recurseIntoAttrs (pkgs.makeRustPlatform {
rust = pkgs.rust-bin.stable."1.66.0".default.override {
extensions = [ "rust-src" ];
targets = [ "thumbv7em-none-eabihf" ];
};
rustPlatform = pkgs.makeRustPlatform {
rustc = rust;
cargo = rust;
});
};
thermostat = rustPlatform.buildRustPackage {
name = "thermostat";
version = "0.0.0";
@ -40,8 +36,7 @@
cargoLock = {
lockFile = ./Cargo.lock;
outputHashes = {
"stm32-eth-0.2.0" =
"sha256-48RpZgagUqgVeKm7GXdk3Oo0v19ScF9Uby0nTFlve2o=";
"stm32-eth-0.2.0" = "sha256-48RpZgagUqgVeKm7GXdk3Oo0v19ScF9Uby0nTFlve2o=";
};
};
@ -82,7 +77,10 @@
inherit pname version;
hash = "sha256-WBCNhBHHBU4IQdi3ke6F4QH8KWubNZwOAd3jipj/Ks4=";
};
propagatedBuildInputs = with pkgs.python3Packages; [ numpy pyqt6 ];
propagatedBuildInputs = with pkgs.python3Packages; [
numpy
pyqt6
];
};
qtextras = pkgs.python3Packages.buildPythonPackage rec {
@ -110,22 +108,12 @@
hash = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A=";
};
buildInputs = [ pkgs.python3Packages.poetry-core ];
propagatedBuildInputs = [ pyqtgraph pkgs.python3Packages.numpy ];
propagatedBuildInputs = [
pyqtgraph
pkgs.python3Packages.numpy
];
};
pytec-dev-wrappers = pkgs.runCommandNoCC "pytec-dev-wrappers" { } ''
mkdir -p $out/bin
for program in ${self}/pytec/*.py; do
if [ -x $program ]; then
progname=`basename -s .py $program`
outname=$out/bin/$progname
echo "#!${pkgs.bash}/bin/bash" >> $outname
echo "exec python3 -m pytec.$progname \"\$@\"" >> $outname
chmod 755 $outname
fi
done
'';
thermostat_gui = pkgs.python3Packages.buildPythonPackage {
pname = "thermostat_gui";
version = "0.0.0";
@ -133,7 +121,8 @@
src = "${self}/pytec";
nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ];
propagatedBuildInputs = [ pkgs.qt6.qtbase ]
propagatedBuildInputs =
[ pkgs.qt6.qtbase ]
++ (with pkgs.python3Packages; [
pyqtgraph
pyqt6
@ -147,22 +136,31 @@
wrapQtApp "$out/bin/tec_qt"
'';
};
in {
packages.x86_64-linux = { inherit thermostat thermostat_gui; };
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt;
in
{
packages.x86_64-linux = {
inherit thermostat thermostat_gui;
default = thermostat;
};
apps.x86_64-linux.thermostat_gui = {
type = "app";
program = "${self.packages.x86_64-linux.thermostat_gui}/bin/tec_qt";
};
hydraJobs = { inherit thermostat; };
hydraJobs = {
inherit thermostat;
};
devShell.x86_64-linux = pkgs.mkShell {
devShells.x86_64-linux.default = pkgs.mkShellNoCC {
name = "thermostat-dev-shell";
buildInputs = with pkgs;
[ rust openocd dfu-util pytec-dev-wrappers ]
packages =
with pkgs;
[
rust
openocd
dfu-util
]
++ (with python3Packages; [
numpy
matplotlib
@ -173,10 +171,6 @@
pglive
qtextras
]);
shellHook = ''
export PYTHONPATH=`pwd`/pytec:$PYTHONPATH
'';
};
defaultPackage.x86_64-linux = thermostat;
};
}

View File

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

0
pytec/autotune.py Executable file → Normal file
View File

View File

@ -4,7 +4,7 @@ from pytec.aioclient import AsyncioClient
async def main():
tec = AsyncioClient()
await tec.connect() # (host="192.168.1.26", port=23)
await tec.start_session() # (host="192.168.1.26", port=23)
await tec.set_param("s-h", 1, "t0", 20)
print(await tec.get_pwm())
print(await tec.get_pid())

0
pytec/plot.py Executable file → Normal file
View File

View File

@ -14,7 +14,7 @@ license = {text = "GPLv3"}
tec_qt = "tec_qt:main"
[tool.setuptools]
packages.find = {}
packages.find = {} # Use setuptools custom discovery, package directory structure isn't standard
py-modules = ["autotune", "plot", "tec_qt"]
[tool.pylint.format]

View File

@ -7,29 +7,56 @@ class CommandError(Exception):
pass
class StoppedConnecting(Exception):
pass
class AsyncioClient:
def __init__(self):
self._reader = None
self._writer = None
self._connecting_task = None
self._command_lock = asyncio.Lock()
self._report_mode_on = False
self.timeout = None
async def connect(self, host="192.168.1.26", port=23):
"""Connect to Thermostat at specified host and port.
async def start_session(self, host="192.168.1.26", port=23, timeout=None):
"""Start session to Thermostat at specified host and port.
Throws StoppedConnecting if disconnect was called while connecting.
Throws asyncio.TimeoutError if timeout was exceeded.
Example::
client = AsyncioClient()
await client.connect()
try:
await client.start_session()
except StoppedConnecting:
print("Stopped connecting")
"""
self._reader, self._writer = await asyncio.open_connection(host, port)
self._connecting_task = asyncio.create_task(
asyncio.wait_for(asyncio.open_connection(host, port), timeout)
)
self.timeout = timeout
try:
self._reader, self._writer = await self._connecting_task
except asyncio.CancelledError as exc:
raise StoppedConnecting from exc
finally:
self._connecting_task = None
await self._check_zero_limits()
def connecting(self):
"""Returns True if client is connecting"""
return self._connecting_task is not None
def connected(self):
"""Returns True if client is connected"""
return self._writer is not None
async def disconnect(self):
"""Disconnect from the Thermostat"""
async def end_session(self):
"""End session to Thermostat if connected, cancel connection if connecting"""
if self._connecting_task is not None:
self._connecting_task.cancel()
if self._writer is None:
return
@ -53,7 +80,9 @@ class AsyncioClient:
async def _read_line(self):
# read 1 line
chunk = await self._reader.readline()
chunk = await asyncio.wait_for(
self._reader.readline(), self.timeout
) # Only wait for response until timeout
return chunk.decode("utf-8", errors="ignore")
async def _read_write(self, command):
@ -67,7 +96,7 @@ class AsyncioClient:
line = await self._read_write(command)
response = json.loads(line)
logging.debug("%s: %s", command, response)
logging.debug(f"{command}: {response}")
if "error" in response:
raise CommandError(response["error"])
return response
@ -218,8 +247,6 @@ class AsyncioClient:
async def save_config(self, channel=""):
"""Save current configuration to EEPROM"""
await self._command("save", str(channel))
if channel == "":
await self._read_line() # Read the extra {}
async def load_config(self, channel=""):
"""Load current configuration from EEPROM"""
@ -240,7 +267,7 @@ class AsyncioClient:
self._writer.write("reset\n".encode("utf-8"))
await self._writer.drain()
await self.disconnect()
await self.end_session()
async def dfu(self):
"""Put the Thermostat in DFU update mode
@ -253,7 +280,7 @@ class AsyncioClient:
self._writer.write("dfu\n".encode("utf-8"))
await self._writer.drain()
await self.disconnect()
await self.end_session()
async def ipv4(self):
"""Get the IPv4 settings of the Thermostat"""

View File

@ -1,18 +1,13 @@
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal
from PyQt6.QtCore import QObject, pyqtSlot
from qasync import asyncSlot
from autotune import PIDAutotuneState, PIDAutotune
class PIDAutoTuner(QObject):
autotune_state_changed = pyqtSignal(int, PIDAutotuneState)
def __init__(self, parent, thermostat, num_of_channel):
super().__init__(parent)
self._thermostat = thermostat
self._thermostat.report_update.connect(self.tick)
self._thermostat.interval_update.connect(self.update_sampling_interval)
self.autotuners = [PIDAutotune(25) for _ in range(num_of_channel)]
self.target_temp = [20.0 for _ in range(num_of_channel)]
self.test_current = [1.0 for _ in range(num_of_channel)]
@ -39,11 +34,9 @@ class PIDAutoTuner(QObject):
self.lookback[ch],
)
self.autotuners[ch].setReady()
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
async def stop_pid_from_running(self, ch):
self.autotuners[ch].setOff()
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
await self._thermostat.set_param("pwm", ch, "i_set", 0)
@asyncSlot(list)
@ -69,7 +62,6 @@ class PIDAutoTuner(QObject):
case PIDAutotuneState.STATE_SUCCEEDED:
kp, ki, kd = self.autotuners[ch].get_tec_pid()
self.autotuners[ch].setOff()
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
await self._thermostat.set_param("pid", ch, "kp", kp)
await self._thermostat.set_param("pid", ch, "ki", ki)
@ -81,5 +73,4 @@ class PIDAutoTuner(QObject):
)
case PIDAutotuneState.STATE_FAILED:
self.autotuners[ch].setOff()
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
await self._thermostat.set_param("pwm", ch, "i_set", 0)

View File

@ -3,16 +3,9 @@ from qasync import asyncSlot
from pytec.gui.model.property import Property, PropertyMeta
import asyncio
import logging
from enum import Enum
from pytec.aioclient import AsyncioClient
class ThermostatConnectionState(Enum):
DISCONNECTED = "disconnected"
CONNECTING = "connecting"
CONNECTED = "connected"
class Thermostat(QObject, metaclass=PropertyMeta):
hw_rev = Property(dict)
fan = Property(dict)
@ -24,7 +17,6 @@ class Thermostat(QObject, metaclass=PropertyMeta):
report = Property(list)
info_box_trigger = pyqtSignal(str, str)
connection_error = pyqtSignal()
connection_state_changed = pyqtSignal(ThermostatConnectionState)
def __init__(self, parent, update_s):
self._update_s = update_s
@ -32,63 +24,51 @@ class Thermostat(QObject, metaclass=PropertyMeta):
self._watch_task = None
self._report_mode_task = None
self._poll_for_report = True
self._update_params_task = None
self.connection_errored = False
super().__init__(parent)
async def start_session(self, host, port):
self.connection_state_changed.emit(ThermostatConnectionState.CONNECTING)
await self._client.connect(host, port)
await self.get_hw_rev()
self.connection_state_changed.emit(ThermostatConnectionState.CONNECTED)
self.start_watching()
await self._client.start_session(host, port, timeout=5)
async def run(self):
self._update_params_task = asyncio.create_task(self.update_params())
self.task = asyncio.create_task(self.update_params())
while True:
if self._update_params_task.done():
try:
self._update_params_task.result()
except OSError:
logging.error(
"Encountered an error while polling for information from Thermostat.",
exc_info=True,
)
self.connection_error.emit()
return
self._update_params_task = asyncio.create_task(self.update_params())
if self.task.done():
if self.task.exception() is not None:
try:
raise self.task.exception()
except asyncio.TimeoutError:
logging.error(
"Encountered an error while updating parameter tree.",
exc_info=True,
)
self.connection_error.emit()
return
_ = self.task.result()
self.task = asyncio.create_task(self.update_params())
await asyncio.sleep(self._update_s)
@pyqtSlot()
def timed_out(self):
self.connection_errored = True
async def get_hw_rev(self):
self.hw_rev = await self._client.hw_rev()
return self.hw_rev
async def update_params(self):
fan_task = asyncio.create_task(self._client.get_fan())
pwm_task = asyncio.create_task(self._client.get_pwm())
pid_task = asyncio.create_task(self._client.get_pid())
report_task = asyncio.create_task(self._client.report())
thermistor_task = asyncio.create_task(self._client.get_steinhart_hart())
postfilter_task = asyncio.create_task(self._client.get_postfilter())
self.fan = await fan_task
self.pwm = await pwm_task
self.fan = await self._client.get_fan()
self.pwm = await self._client.get_pwm()
if self._poll_for_report:
self.report = await report_task
self.report = await self._client.report()
self.interval = [
self.report[i]["interval"] for i in range(len(self.report))
]
self.pid = await pid_task
self.thermistor = await thermistor_task
self.postfilter = await postfilter_task
self.pid = await self._client.get_pid()
self.thermistor = await self._client.get_steinhart_hart()
self.postfilter = await self._client.get_postfilter()
def connected(self):
return self._client.connected()
def connecting(self):
return self._client.connecting()
def start_watching(self):
self._watch_task = asyncio.create_task(self.run())
@ -98,8 +78,8 @@ class Thermostat(QObject, metaclass=PropertyMeta):
await self.set_report_mode(False)
self._watch_task.cancel()
self._watch_task = None
self._update_params_task.cancel()
self._update_params_task = None
self.task.cancel()
self.task = None
async def set_report_mode(self, enabled: bool):
self._poll_for_report = not enabled
@ -115,13 +95,8 @@ class Thermostat(QObject, metaclass=PropertyMeta):
self.report[i]["interval"] for i in range(len(self.report))
]
@asyncSlot()
async def end_session(self):
await self.set_report_mode(False)
self.stop_watching()
await self._client.disconnect()
self.connection_state_changed.emit(ThermostatConnectionState.DISCONNECTED)
self.connection_errored = False
await self._client.end_session()
async def set_ipv4(self, ipv4):
await self._client.set_param("ipv4", ipv4)
@ -130,12 +105,18 @@ class Thermostat(QObject, metaclass=PropertyMeta):
return await self._client.ipv4()
@asyncSlot()
async def save_cfg(self, ch=""):
async def save_cfg(self, ch):
await self._client.save_config(ch)
self.info_box_trigger.emit(
"Settings loaded", f"Channel {ch} Settings has been saved to flash."
)
@asyncSlot()
async def load_cfg(self, ch=""):
async def load_cfg(self, ch):
await self._client.load_config(ch)
self.info_box_trigger.emit(
"Settings loaded", f"Channel {ch} Settings has been loaded from flash."
)
async def dfu(self):
await self._client.dfu()

View File

@ -1,12 +1,9 @@
from functools import partial
from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot
import pyqtgraph.parametertree.parameterTypes as pTypes
from pyqtgraph.parametertree import (
Parameter,
registerParameterType,
)
from qasync import asyncSlot
from autotune import PIDAutotuneState
class MutexParameter(pTypes.ListParameter):
@ -45,23 +42,26 @@ class MutexParameter(pTypes.ListParameter):
registerParameterType("mutex", MutexParameter)
def set_tree_label_tips(tree):
for item in tree.listAllItems():
p = item.param
if "tip" in p.opts:
item.setToolTip(0, p.opts["tip"])
class CtrlPanel(QObject):
set_zero_limits_warning_sig = pyqtSignal(list)
def __init__(
self,
thermostat,
autotuners,
info_box,
trees_ui,
param_tree,
sigTreeStateChanged_handle,
sigActivated_handles,
parent=None,
):
super().__init__(parent)
self.thermostat = thermostat
self.autotuners = autotuners
self.info_box = info_box
self.trees_ui = trees_ui
self.NUM_CHANNELS = len(trees_ui)
@ -84,23 +84,12 @@ class CtrlPanel(QObject):
tree.setHeaderHidden(True)
tree.setParameters(self.params[i], showTop=False)
self.params[i].setValue = self._setValue
self.params[i].sigTreeStateChanged.connect(self.send_command)
self.params[i].sigTreeStateChanged.connect(sigTreeStateChanged_handle)
self.params[i].child("Save to flash").sigActivated.connect(
partial(self.save_settings, i)
)
self.params[i].child("Load from flash").sigActivated.connect(
partial(self.load_settings, i)
)
self.params[i].child(
"PID Config", "PID Auto Tune", "Run"
).sigActivated.connect(partial(self.pid_auto_tune_request, i))
set_tree_label_tips(tree)
self.thermostat.pid_update.connect(self.update_pid)
self.thermostat.report_update.connect(self.update_report)
self.thermostat.thermistor_update.connect(self.update_thermistor)
self.thermostat.pwm_update.connect(self.update_pwm)
self.thermostat.postfilter_update.connect(self.update_postfilter)
for handle in sigActivated_handles[i]:
self.params[i].child(*handle[0]).sigActivated.connect(handle[1])
def _setValue(self, value, blockSignal=None):
"""
@ -131,66 +120,28 @@ class CtrlPanel(QObject):
def change_params_title(self, channel, path, title):
self.params[channel].child(*path).setOpts(title=title)
@asyncSlot(object, object)
async def send_command(self, param, changes):
"""Translates parameter tree changes into thermostat set_param calls"""
ch = param.channel
for inner_param, change, data in changes:
if change == "value":
if inner_param.opts.get("param", None) is not None:
if inner_param.opts.get("suffix", None) == "mA":
data /= 1000 # Given in mA
thermostat_param = inner_param.opts["param"]
if thermostat_param[1] == "ch":
thermostat_param[1] = ch
if inner_param.name() == "Postfilter Rate" and data is None:
set_param_args = (*thermostat_param[:2], "off")
else:
set_param_args = (*thermostat_param, data)
param.child(*param.childPath(inner_param)).setOpts(lock=True)
await self.thermostat.set_param(*set_param_args)
param.child(*param.childPath(inner_param)).setOpts(lock=False)
if inner_param.opts.get("pid_autotune", None) is not None:
auto_tuner_param = inner_param.opts["pid_autotune"][0]
if inner_param.opts["pid_autotune"][1] != "ch":
ch = inner_param.opts["pid_autotune"][1]
self.autotuners.set_params(auto_tuner_param, ch, data)
if inner_param.opts.get("activaters", None) is not None:
activater = inner_param.opts["activaters"][
inner_param.opts["limits"].index(data)
]
if activater is not None:
if activater[1] == "ch":
activater[1] = ch
await self.thermostat.set_param(*activater)
@pyqtSlot("QVariantList")
def update_pid(self, pid_settings):
for settings in pid_settings:
channel = settings["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("PID Config", "Kp").setValue(
self.params[channel].child("pid", "kp").setValue(
settings["parameters"]["kp"]
)
self.params[channel].child("PID Config", "Ki").setValue(
self.params[channel].child("pid", "ki").setValue(
settings["parameters"]["ki"]
)
self.params[channel].child("PID Config", "Kd").setValue(
self.params[channel].child("pid", "kd").setValue(
settings["parameters"]["kd"]
)
self.params[channel].child(
"PID Config", "PID Output Clamping", "Minimum"
"pid", "pid_output_clamping", "output_min"
).setValue(settings["parameters"]["output_min"] * 1000)
self.params[channel].child(
"PID Config", "PID Output Clamping", "Maximum"
"pid", "pid_output_clamping", "output_max"
).setValue(settings["parameters"]["output_max"] * 1000)
self.params[channel].child(
"Output Config", "Control Method", "Set Temperature"
"output", "control_method", "set_temperature"
).setValue(settings["target"])
@pyqtSlot("QVariantList")
@ -198,19 +149,19 @@ class CtrlPanel(QObject):
for settings in report_data:
channel = settings["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("Output Config", "Control Method").setValue(
"Temperature PID" if settings["pid_engaged"] else "Constant Current"
self.params[channel].child("output", "control_method").setValue(
"temperature_pid" if settings["pid_engaged"] else "constant_current"
)
self.params[channel].child(
"Output Config", "Control Method", "Set Current"
"output", "control_method", "set_current"
).setValue(settings["i_set"] * 1000)
if settings["temperature"] is not None:
self.params[channel].child("Temperature").setValue(
self.params[channel].child("temperature").setValue(
settings["temperature"]
)
if settings["tec_i"] is not None:
self.params[channel].child("Current through TEC").setValue(
settings["tec_i"] * 1000
self.params[channel].child("current").setValue(
settings["tec_i"]
)
@pyqtSlot("QVariantList")
@ -218,13 +169,13 @@ class CtrlPanel(QObject):
for sh_param in sh_data:
channel = sh_param["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child("Thermistor Config", "T₀").setValue(
self.params[channel].child("thermistor", "t0").setValue(
sh_param["params"]["t0"] - 273.15
)
self.params[channel].child("Thermistor Config", "R₀").setValue(
self.params[channel].child("thermistor", "r0").setValue(
sh_param["params"]["r0"]
)
self.params[channel].child("Thermistor Config", "B").setValue(
self.params[channel].child("thermistor", "b").setValue(
sh_param["params"]["b"]
)
@ -235,15 +186,15 @@ class CtrlPanel(QObject):
for pwm_params in pwm_data:
channel = pwm_params["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child(
"Output Config", "Limits", "Max Voltage Difference"
).setValue(pwm_params["max_v"]["value"])
self.params[channel].child(
"Output Config", "Limits", "Max Cooling Current"
).setValue(pwm_params["max_i_pos"]["value"] * 1000)
self.params[channel].child(
"Output Config", "Limits", "Max Heating Current"
).setValue(pwm_params["max_i_neg"]["value"] * 1000)
self.params[channel].child("output", "limits", "max_v").setValue(
pwm_params["max_v"]["value"]
)
self.params[channel].child("output", "limits", "max_i_pos").setValue(
pwm_params["max_i_pos"]["value"] * 1000
)
self.params[channel].child("output", "limits", "max_i_neg").setValue(
pwm_params["max_i_neg"]["value"] * 1000
)
for limit in "max_i_pos", "max_i_neg", "max_v":
if pwm_params[limit]["value"] == 0.0:
@ -255,39 +206,6 @@ class CtrlPanel(QObject):
for postfilter_params in postfilter_data:
channel = postfilter_params["channel"]
with QSignalBlocker(self.params[channel]):
self.params[channel].child(
"Thermistor Config", "Postfilter Rate"
).setValue(postfilter_params["rate"])
@asyncSlot(int)
async def load_settings(self, ch):
await self.thermostat.load_cfg(ch)
self.info_box.display_info_box(
f"Channel {ch} settings loaded",
f"Channel {ch} settings has been loaded from flash.",
)
@asyncSlot(int)
async def save_settings(self, ch):
await self.thermostat.save_cfg(ch)
self.info_box.display_info_box(
f"Channel {ch} settings saved",
f"Channel {ch} settings has been saved to flash.\n"
"It will be loaded on Thermostat reset, or when settings are explicitly loaded.",
)
@asyncSlot()
async def pid_auto_tune_request(self, ch=0):
match self.autotuners.get_state(ch):
case PIDAutotuneState.STATE_OFF | PIDAutotuneState.STATE_FAILED:
self.autotuners.load_params_and_set_ready(ch)
case (
PIDAutotuneState.STATE_READY
| PIDAutotuneState.STATE_RELAY_STEP_UP
| PIDAutotuneState.STATE_RELAY_STEP_DOWN
):
await self.autotuners.stop_pid_from_running(ch)
self.params[channel].child("thermistor", "postfilter_rate").setValue(
postfilter_params["rate"]
)

View File

@ -10,12 +10,8 @@ pg.setConfigOptions(antialias=True)
class LiveDataPlotter(QObject):
def __init__(self, thermostat, live_plots):
def __init__(self, live_plots):
super().__init__()
self._thermostat = thermostat
self._thermostat.report_update.connect(self.update_report)
self._thermostat.pid_update.connect(self.update_pid)
self.NUM_CHANNELS = len(live_plots)
self.graphs = []

View File

@ -1,365 +1,407 @@
{
"ctrl_panel":[
{
"name":"Temperature",
"type":"float",
"format":"{value:.4f} °C",
"readonly":true
},
{
"name":"Current through TEC",
"type":"float",
"suffix":"mA",
"decimals":6,
"readonly":true
},
{
"name":"Output Config",
"expanded":true,
"type":"group",
"children":[
{
"name":"Control Method",
"type":"mutex",
"limits":[
"Constant Current",
"Temperature PID"
],
"activaters":[
null,
[
"pwm",
"ch",
"pid"
]
],
"children":[
{
"name":"Set Current",
"type":"float",
"value":0,
"step":100,
"limits":[
-2000,
2000
],
"triggerOnShow":true,
"decimals":6,
"suffix":"mA",
"param":[
"pwm",
"ch",
"i_set"
],
"lock":false
},
{
"name":"Set Temperature",
"type":"float",
"value":25,
"step":0.1,
"limits":[
-273,
300
],
"format":"{value:.4f} °C",
"param":[
"pid",
"ch",
"target"
],
"lock":false
}
]
},
{
"name":"Limits",
"expanded":true,
"type":"group",
"children":[
{
"name":"Max Cooling Current",
"type":"float",
"value":0,
"step":100,
"decimals":6,
"limits":[
0,
2000
],
"suffix":"mA",
"param":[
"pwm",
"ch",
"max_i_pos"
],
"lock":false
},
{
"name":"Max Heating Current",
"type":"float",
"value":0,
"step":100,
"decimals":6,
"limits":[
0,
2000
],
"suffix":"mA",
"param":[
"pwm",
"ch",
"max_i_neg"
],
"lock":false
},
{
"name":"Max Voltage Difference",
"type":"float",
"value":0,
"step":0.1,
"limits":[
0,
5
],
"siPrefix":true,
"suffix":"V",
"param":[
"pwm",
"ch",
"max_v"
],
"lock":false
}
]
}
]
},
{
"name":"Thermistor Config",
"expanded":true,
"type":"group",
"children":[
{
"name":"T₀",
"type":"float",
"value":25,
"step":0.1,
"limits":[
-100,
100
],
"format":"{value:.4f} °C",
"param":[
"s-h",
"ch",
"t0"
],
"lock":false
},
{
"name":"R₀",
"type":"float",
"value":10000,
"step":1,
"siPrefix":true,
"suffix":"Ω",
"param":[
"s-h",
"ch",
"r0"
],
"lock":false
},
{
"name":"B",
"type":"float",
"value":3950,
"step":1,
"suffix":"K",
"decimals":4,
"param":[
"s-h",
"ch",
"b"
],
"lock":false
},
{
"name":"Postfilter Rate",
"type":"list",
"value":16.67,
"param":[
"postfilter",
"ch",
"rate"
],
"limits":{
"Off":null,
"16.67 Hz":16.67,
"20 Hz":20.0,
"21.25 Hz":21.25,
"27 Hz":27.0
},
"lock":false
}
]
},
{
"name":"PID Config",
"expanded":true,
"type":"group",
"children":[
{
"name":"Kp",
"type":"float",
"step":0.1,
"suffix":"",
"param":[
"pid",
"ch",
"kp"
],
"lock":false
},
{
"name":"Ki",
"type":"float",
"step":0.1,
"suffix":"Hz",
"param":[
"pid",
"ch",
"ki"
],
"lock":false
},
{
"name":"Kd",
"type":"float",
"step":0.1,
"suffix":"s",
"param":[
"pid",
"ch",
"kd"
],
"lock":false
},
{
"name":"PID Output Clamping",
"expanded":true,
"type":"group",
"children":[
{
"name":"Minimum",
"type":"float",
"step":100,
"limits":[
-2000,
2000
],
"decimals":6,
"suffix":"mA",
"param":[
"pid",
"ch",
"output_min"
],
"lock":false
},
{
"name":"Maximum",
"type":"float",
"step":100,
"limits":[
-2000,
2000
],
"decimals":6,
"suffix":"mA",
"param":[
"pid",
"ch",
"output_max"
],
"lock":false
}
]
},
{
"name":"PID Auto Tune",
"expanded":false,
"type":"group",
"children":[
{
"name":"Target Temperature",
"type":"float",
"value":20,
"step":0.1,
"format":"{value:.4f} °C",
"pid_autotune":[
"target_temp",
"ch"
]
},
{
"name":"Test Current",
"type":"float",
"value":0,
"decimals":6,
"step":100,
"limits":[
-2000,
2000
],
"suffix":"mA",
"pid_autotune":[
"test_current",
"ch"
]
},
{
"name":"Temperature Swing",
"type":"float",
"value":1.5,
"step":0.1,
"prefix":"±",
"format":"{value:.4f} °C",
"pid_autotune":[
"temp_swing",
"ch"
]
},
{
"name":"Lookback",
"type":"float",
"value":3.0,
"step":0.1,
"format":"{value:.4f} s",
"pid_autotune":[
"ctrl_panel": [
{
"name": "temperature",
"title": "Temperature",
"type": "float",
"format": "{value:.4f} °C",
"readonly": true
},
{
"name": "current",
"title": "Current through TEC",
"type": "float",
"siPrefix": true,
"suffix": "A",
"decimals": 6,
"readonly": true
},
{
"name": "output",
"title": "Output Settings",
"expanded": true,
"type": "group",
"children": [
{
"name": "control_method",
"title": "Control Method",
"type": "mutex",
"limits": {
"Constant Current": "constant_current",
"Temperature PID": "temperature_pid"
},
"activaters": [
null,
[
"pwm",
"ch",
"pid"
]
],
"children": [
{
"name": "set_current",
"title": "Set Current (mA)",
"type": "float",
"value": 0,
"step": 100,
"limits": [
-2000,
2000
],
"triggerOnShow": true,
"decimals": 6,
"compactHeight": false,
"param": [
"pwm",
"ch",
"i_set"
],
"lock": false
},
{
"name": "set_temperature",
"title": "Set Temperature (°C)",
"type": "float",
"value": 25,
"step": 0.1,
"limits": [
-273,
300
],
"format": "{value:.4f}",
"compactHeight": false,
"param": [
"pid",
"ch",
"target"
],
"lock": false
}
]
},
{
"name": "limits",
"title": "Limits",
"expanded": true,
"type": "group",
"children": [
{
"name": "max_i_pos",
"title": "Max Cooling Current (mA)",
"type": "float",
"value": 0,
"step": 100,
"decimals": 6,
"compactHeight": false,
"limits": [
0,
2000
],
"param": [
"pwm",
"ch",
"max_i_pos"
],
"lock": false
},
{
"name": "max_i_neg",
"title": "Max Heating Current (mA)",
"type": "float",
"value": 0,
"step": 100,
"decimals": 6,
"compactHeight": false,
"limits": [
0,
2000
],
"param": [
"pwm",
"ch",
"max_i_neg"
],
"lock": false
},
{
"name": "max_v",
"title": "Max Voltage Difference (V)",
"type": "float",
"value": 0,
"step": 0.1,
"limits": [
0,
5
],
"siPrefix": true,
"compactHeight": false,
"param": [
"pwm",
"ch",
"max_v"
],
"lock": false
}
]
}
]
},
{
"name": "thermistor",
"title": "Thermistor Settings",
"expanded": true,
"type": "group",
"tip": "Settings of the connected Thermistor",
"children": [
{
"name": "t0",
"title": "T₀ (°C)",
"type": "float",
"value": 25,
"step": 0.1,
"limits": [
-100,
100
],
"format": "{value:.4f}",
"compactHeight": false,
"param": [
"s-h",
"ch",
"t0"
],
"tip": "The origin temperature for the B-Parameter Formula",
"lock": false
},
{
"name": "r0",
"title": "R₀ (Ω)",
"type": "float",
"value": 10000,
"step": 1,
"siPrefix": true,
"compactHeight": false,
"param": [
"s-h",
"ch",
"r0"
],
"tip": "The origin resistance for the B-Parameter Formula",
"lock": false
},
{
"name": "b",
"title": "B (K)",
"type": "float",
"value": 3950,
"step": 1,
"decimals": 4,
"compactHeight": false,
"param": [
"s-h",
"ch",
"b"
],
"lock": false
},
{
"name": "postfilter_rate",
"title": "Postfilter Rate",
"type": "list",
"value": 16.67,
"param": [
"postfilter",
"ch",
"rate"
],
"limits": {
"Off": null,
"16.67 Hz": 16.67,
"20 Hz": 20.0,
"21.25 Hz": 21.25,
"27 Hz": 27.0
},
"lock": false
}
]
},
{
"name": "pid",
"title": "PID Settings",
"expanded": true,
"type": "group",
"children": [
{
"name": "kp",
"title": "Kp",
"type": "float",
"step": 0.1,
"suffix": "",
"compactHeight": false,
"param": [
"pid",
"ch",
"kp"
],
"lock": false
},
{
"name": "ki",
"title": "Ki",
"type": "float",
"step": 0.1,
"suffix": "Hz",
"compactHeight": false,
"param": [
"pid",
"ch",
"ki"
],
"lock": false
},
{
"name": "kd",
"title": "Kd",
"type": "float",
"step": 0.1,
"suffix": "s",
"compactHeight": false,
"param": [
"pid",
"ch",
"kd"
],
"lock": false
},
{
"name": "pid_output_clamping",
"title": "PID Output Clamping",
"expanded": true,
"type": "group",
"children": [
{
"name": "output_min",
"title": "Minimum (mA)",
"type": "float",
"step": 100,
"limits": [
-2000,
2000
],
"decimals": 6,
"compactHeight": false,
"param": [
"pid",
"ch",
"output_min"
],
"lock": false
},
{
"name": "output_max",
"title": "Maximum (mA)",
"type": "float",
"step": 100,
"limits": [
-2000,
2000
],
"decimals": 6,
"compactHeight": false,
"param": [
"pid",
"ch",
"output_max"
],
"lock": false
}
]
},
{
"name": "pid_autotune",
"title": "PID Autotune",
"expanded": false,
"type": "group",
"children": [
{
"name": "target_temp",
"title": "Target Temperature (°C)",
"type": "float",
"value": 20,
"step": 0.1,
"format": "{value:.4f}",
"compactHeight": false,
"pid_autotune": [
"target_temp",
"ch"
]
},
{
"name": "test_current",
"title": "Test Current (mA)",
"type": "float",
"value": 0,
"decimals": 6,
"compactHeight": false,
"step": 100,
"limits": [
-2000,
2000
],
"pid_autotune": [
"test_current",
"ch"
]
},
{
"name": "temp_swing",
"title": "Temperature Swing (°C)",
"type": "float",
"value": 1.5,
"step": 0.1,
"prefix": "±",
"format": "{value:.4f}",
"compactHeight": false,
"pid_autotune": [
"temp_swing",
"ch"
]
},
{
"name": "lookback",
"title": "Lookback (s)",
"type": "float",
"value": 3.0,
"step": 0.1,
"format": "{value:.4f}",
"compactHeight": false,
"pid_autotune": [
"lookback",
"ch"
]
},
{
"name":"Run",
"type":"action",
"tip":"Run"
}
]
}
]
},
{
"name":"Save to flash",
"type":"action",
"tip":"Save config to thermostat, applies on reset"
},
{
"name":"Load from flash",
"type":"action",
"tip":"Load config from flash"
}
]
}
{
"name": "run_pid",
"title": "Run",
"type": "action",
"tip": "Run PID Autotune with above settings"
}
]
}
]
},
{
"name": "save",
"title": "Save to flash",
"type": "action",
"tip": "Save settings to thermostat, applies on reset"
},
{
"name": "load",
"title": "Load from flash",
"type": "action",
"tip": "Load settings from flash"
}
]
}

View File

@ -2,22 +2,18 @@ from PyQt6 import QtWidgets, QtGui
class PlotOptionsMenu(QtWidgets.QMenu):
def __init__(self, channel_graphs, max_samples=1000):
def __init__(self, max_samples=1000):
super().__init__()
self.channel_graphs = channel_graphs
self.setTitle("Plot Settings")
clear = QtGui.QAction("Clear graphs", self)
self.addAction(clear)
self.clear = clear
self.clear.triggered.connect(self.channel_graphs.clear_graphs)
self.samples_spinbox = QtWidgets.QSpinBox()
self.samples_spinbox.setRange(2, 100000)
self.samples_spinbox.setSuffix(" samples")
self.samples_spinbox.setValue(max_samples)
self.samples_spinbox.valueChanged.connect(self.channel_graphs.set_max_samples)
limit_samples = QtWidgets.QWidgetAction(self)
limit_samples.setDefaultWidget(self.samples_spinbox)

View File

@ -1,20 +1,24 @@
import logging
from PyQt6 import QtWidgets, QtGui, QtCore
from PyQt6.QtCore import pyqtSignal, pyqtSlot, QSignalBlocker
from qasync import asyncSlot
from pytec.gui.view.net_settings_input_diag import NetSettingsInputDiag
from PyQt6.QtCore import pyqtSignal, pyqtSlot
class ThermostatCtrlMenu(QtWidgets.QMenu):
def __init__(self, thermostat, info_box, style):
fan_set_act = pyqtSignal(int)
fan_auto_set_act = pyqtSignal(int)
connect_act = pyqtSignal()
reset_act = pyqtSignal(bool)
dfu_act = pyqtSignal(bool)
load_cfg_act = pyqtSignal(int)
save_cfg_act = pyqtSignal(int)
net_cfg_act = pyqtSignal(bool)
def __init__(self, style):
super().__init__()
self._thermostat = thermostat
self._info_box = info_box
self._style = style
self.setTitle("Thermostat settings")
self.hw_rev_data = dict()
self._thermostat.hw_rev_update.connect(self.hw_rev)
self.fan_group = QtWidgets.QWidget()
self.fan_group.setEnabled(False)
@ -41,9 +45,8 @@ class ThermostatCtrlMenu(QtWidgets.QMenu):
self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0))
self.fan_layout.addWidget(self.fan_pwm_warning)
self.fan_power_slider.valueChanged.connect(self.fan_set_request)
self.fan_auto_box.stateChanged.connect(self.fan_auto_set_request)
self._thermostat.fan_update.connect(self.fan_update)
self.fan_power_slider.valueChanged.connect(self.fan_set_act)
self.fan_auto_box.stateChanged.connect(self.fan_auto_set_act)
self.fan_lbl.setToolTip("Adjust the fan")
self.fan_lbl.setText("Fan:")
@ -55,36 +58,40 @@ class ThermostatCtrlMenu(QtWidgets.QMenu):
self.fan = fan
self.actionReset = QtGui.QAction("Reset Thermostat", self)
self.actionReset.triggered.connect(self.reset_request)
self.actionReset.triggered.connect(self.reset_act)
self.addAction(self.actionReset)
self.actionEnter_DFU_Mode = QtGui.QAction("Enter DFU Mode", self)
self.actionEnter_DFU_Mode.triggered.connect(self.dfu_request)
self.actionEnter_DFU_Mode.triggered.connect(self.dfu_act)
self.addAction(self.actionEnter_DFU_Mode)
self.actionnet_settings_input_diag = QtGui.QAction("Set IPV4 Settings", self)
self.actionnet_settings_input_diag.triggered.connect(self.net_settings_request)
self.actionnet_settings_input_diag.triggered.connect(self.net_cfg_act)
self.addAction(self.actionnet_settings_input_diag)
@asyncSlot(bool)
async def load(_):
await self._thermostat.load_cfg()
self._info_box.display_info_box(
"Config loaded", "All channel configs have been loaded from flash."
)
@pyqtSlot(bool)
def load(_):
self.load_cfg_act.emit(0)
self.load_cfg_act.emit(1)
loaded = QtWidgets.QMessageBox(self)
loaded.setWindowTitle("Config loaded")
loaded.setText("All channel configs have been loaded from flash.")
loaded.setIcon(QtWidgets.QMessageBox.Icon.Information)
loaded.show()
self.actionLoad_all_configs = QtGui.QAction("Load Config", self)
self.actionLoad_all_configs.triggered.connect(load)
self.addAction(self.actionLoad_all_configs)
@asyncSlot(bool)
async def save(_):
await self._thermostat.save_cfg()
self._info_box.display_info_box(
"Config saved", "All channel configs have been saved to flash."
)
@pyqtSlot(bool)
def save(_):
self.save_cfg_act.emit(0)
self.save_cfg_act.emit(1)
saved = QtWidgets.QMessageBox(self)
saved.setWindowTitle("Config saved")
saved.setText("All channel configs have been saved to flash.")
saved.setIcon(QtWidgets.QMessageBox.Icon.Information)
saved.show()
self.actionSave_all_configs = QtGui.QAction("Save Config", self)
self.actionSave_all_configs.triggered.connect(save)
@ -120,18 +127,6 @@ class ThermostatCtrlMenu(QtWidgets.QMenu):
self.actionAbout_Thermostat.triggered.connect(about_thermostat)
self.addAction(self.actionAbout_Thermostat)
@pyqtSlot("QVariantMap")
def fan_update(self, fan_settings):
logging.debug(fan_settings)
if fan_settings is None:
return
with QSignalBlocker(self.fan_power_slider):
self.fan_power_slider.setValue(
fan_settings["fan_pwm"] or 100 # 0 = PWM off = full strength
)
with QSignalBlocker(self.fan_auto_box):
self.fan_auto_box.setChecked(fan_settings["auto_mode"])
def set_fan_pwm_warning(self):
if self.fan_power_slider.value() != 100:
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
@ -148,55 +143,3 @@ class ThermostatCtrlMenu(QtWidgets.QMenu):
def hw_rev(self, hw_rev):
self.hw_rev_data = hw_rev
self.fan_group.setEnabled(self.hw_rev_data["settings"]["fan_available"])
@asyncSlot(int)
async def fan_set_request(self, value):
assert self._thermostat.connected()
if self.fan_auto_box.isChecked():
with QSignalBlocker(self.fan_auto_box):
self.fan_auto_box.setChecked(False)
await self._thermostat.set_fan(value)
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
self.set_fan_pwm_warning()
@asyncSlot(int)
async def fan_auto_set_request(self, enabled):
assert self._thermostat.connected()
if enabled:
await self._thermostat.set_fan("auto")
self.fan_update(await self._thermostat.get_fan())
else:
await self.thermostat.set_fan(
self.fan_power_slider.value()
)
@asyncSlot(bool)
async def reset_request(self, _):
assert self._thermostat.connected()
await self._thermostat.reset()
await self._thermostat.end_session()
@asyncSlot(bool)
async def dfu_request(self, _):
assert self._thermostat.connected()
await self._thermostat.dfu()
await self._thermostat.end_session()
@asyncSlot(bool)
async def net_settings_request(self, _):
assert self._thermostat.connected()
ipv4 = await self._thermostat.get_ipv4()
self.net_settings_input_diag = NetSettingsInputDiag(ipv4["addr"])
self.net_settings_input_diag.set_ipv4_act.connect(self.set_net_settings_request)
@asyncSlot(str)
async def set_net_settings_request(self, ipv4_settings):
assert self._thermostat.connected()
await self._thermostat.set_ipv4(ipv4_settings)
await self._thermostat.end_session()

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'],
)

343
pytec/tec_qt.py Executable file → Normal file
View File

@ -1,4 +1,5 @@
from pytec.gui.view.zero_limits_warning import ZeroLimitsWarningView
from pytec.gui.view.net_settings_input_diag import NetSettingsInputDiag
from pytec.gui.view.thermostat_ctrl_menu import ThermostatCtrlMenu
from pytec.gui.view.conn_menu import ConnMenu
from pytec.gui.view.plot_options_menu import PlotOptionsMenu
@ -6,11 +7,12 @@ 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
from pytec.gui.model.pid_autotuner import PIDAutoTuner
from pytec.gui.model.thermostat import Thermostat, ThermostatConnectionState
from pytec.gui.model.thermostat import Thermostat
import json
from autotune import PIDAutotuneState
from qasync import asyncSlot, asyncClose
import qasync
from pytec.aioclient import StoppedConnecting
import asyncio
import logging
import argparse
@ -28,9 +30,9 @@ def get_argparser():
"--connect",
default=None,
action="store_true",
help="Automatically connect to the specified Thermostat in host:port format",
help="Automatically connect to the specified Thermostat in IP:port format",
)
parser.add_argument("HOST", metavar="host", default=None, nargs="?")
parser.add_argument("IP", metavar="ip", default=None, nargs="?")
parser.add_argument("PORT", metavar="port", default=None, nargs="?")
parser.add_argument(
"-l",
@ -58,38 +60,47 @@ class MainWindow(QtWidgets.QMainWindow):
ui_file_path = importlib.resources.files("pytec.gui.view").joinpath("tec_qt.ui")
uic.loadUi(ui_file_path, self)
self.hw_rev_data = None
self.info_box = InfoBox()
self.thermostat = Thermostat(
self, self.report_refresh_spin.value()
)
self._connecting_task = None
def handle_connection_error():
logging.error("Client connection error, disconnecting")
self.info_box.display_info_box(
"Connection Error", "Thermostat connection lost. Is it unplugged?"
)
self.bail()
self.thermostat.connection_error.connect(handle_connection_error)
self.thermostat.connection_error.connect(self.thermostat.timed_out)
self.thermostat.connection_error.connect(self.thermostat.end_session)
self.thermostat.connection_state_changed.connect(self._on_connection_changed)
self.autotuners = PIDAutoTuner(self, self.thermostat, 2)
self.autotuners.autotune_state_changed.connect(self.pid_autotune_handler)
def get_ctrl_panel_config(args):
with open(args.param_tree, "r", encoding="utf-8") as f:
with open(args.param_tree, "r") as f:
return json.load(f)["ctrl_panel"]
param_tree_sigActivated_handles = [
[
[["save"], partial(self.thermostat.save_cfg, ch)],
[["load"], partial(self.thermostat.load_cfg, ch)],
[
["pid", "pid_autotune", "run_pid"],
partial(self.pid_auto_tune_request, ch),
],
]
for ch in range(self.NUM_CHANNELS)
]
self.thermostat.info_box_trigger.connect(self.info_box.display_info_box)
self.ctrl_panel_view = CtrlPanel(
self.thermostat,
self.autotuners,
self.info_box,
[self.ch0_tree, self.ch1_tree],
get_ctrl_panel_config(args),
self.send_command,
param_tree_sigActivated_handles,
)
self.zero_limits_warning = ZeroLimitsWarningView(
@ -99,28 +110,55 @@ class MainWindow(QtWidgets.QMainWindow):
self.zero_limits_warning.set_limits_warning
)
self.thermostat.hw_rev_update.connect(self._status)
self.thermostat.fan_update.connect(self.fan_update)
self.thermostat.report_update.connect(self.ctrl_panel_view.update_report)
self.thermostat.report_update.connect(self.autotuners.tick)
self.thermostat.report_update.connect(self.pid_autotune_handler)
self.thermostat.pid_update.connect(self.ctrl_panel_view.update_pid)
self.thermostat.pwm_update.connect(self.ctrl_panel_view.update_pwm)
self.thermostat.thermistor_update.connect(
self.ctrl_panel_view.update_thermistor
)
self.thermostat.postfilter_update.connect(
self.ctrl_panel_view.update_postfilter
)
self.thermostat.interval_update.connect(
self.autotuners.update_sampling_interval
)
self.report_apply_btn.clicked.connect(
lambda: self.thermostat.set_update_s(self.report_refresh_spin.value())
)
self.channel_graphs = LiveDataPlotter(
self.thermostat,
[
[getattr(self, f"ch{ch}_t_graph"), getattr(self, f"ch{ch}_i_graph")]
for ch in range(self.NUM_CHANNELS)
]
)
self.plot_options_menu = PlotOptionsMenu(self.channel_graphs)
self.thermostat.report_update.connect(self.channel_graphs.update_report)
self.thermostat.pid_update.connect(self.channel_graphs.update_pid)
self.plot_options_menu = PlotOptionsMenu()
self.plot_options_menu.clear.triggered.connect(self.clear_graphs)
self.plot_options_menu.samples_spinbox.valueChanged.connect(
self.channel_graphs.set_max_samples
)
self.plot_settings.setMenu(self.plot_options_menu)
self.conn_menu = ConnMenu()
self.connect_btn.setMenu(self.conn_menu)
self.thermostat_ctrl_menu = ThermostatCtrlMenu(
self.thermostat, self.info_box, self.style()
)
self.thermostat_ctrl_menu = ThermostatCtrlMenu(self.style())
self.thermostat_ctrl_menu.fan_set_act.connect(self.fan_set_request)
self.thermostat_ctrl_menu.fan_auto_set_act.connect(self.fan_auto_set_request)
self.thermostat_ctrl_menu.reset_act.connect(self.reset_request)
self.thermostat_ctrl_menu.dfu_act.connect(self.dfu_request)
self.thermostat_ctrl_menu.save_cfg_act.connect(self.save_cfg_request)
self.thermostat_ctrl_menu.load_cfg_act.connect(self.load_cfg_request)
self.thermostat_ctrl_menu.net_cfg_act.connect(self.net_settings_request)
self.thermostat.hw_rev_update.connect(self.thermostat_ctrl_menu.hw_rev)
self.thermostat_settings.setMenu(self.thermostat_ctrl_menu)
self.loading_spinner.hide()
@ -132,97 +170,166 @@ class MainWindow(QtWidgets.QMainWindow):
self.port_set_spin.setValue(int(args.PORT))
self.connect_btn.click()
@asyncSlot(ThermostatConnectionState)
def clear_graphs(self):
self.channel_graphs.clear_graphs()
async def _on_connection_changed(self, result):
match result:
case ThermostatConnectionState.CONNECTED:
self.graph_group.setEnabled(True)
self.report_group.setEnabled(True)
self.thermostat_settings.setEnabled(True)
self.graph_group.setEnabled(result)
self.report_group.setEnabled(result)
self.thermostat_settings.setEnabled(result)
self.conn_menu.host_set_line.setEnabled(False)
self.conn_menu.port_set_spin.setEnabled(False)
self.connect_btn.setText("Disconnect")
self.conn_menu.host_set_line.setEnabled(not result)
self.conn_menu.port_set_spin.setEnabled(not result)
self.connect_btn.setText("Disconnect" if result else "Connect")
if result:
self.hw_rev_data = await self.thermostat.get_hw_rev()
logging.debug(self.hw_rev_data)
case ThermostatConnectionState.CONNECTING:
self.status_lbl.setText("Connecting...")
self.connect_btn.setText("Stop")
self.conn_menu.host_set_line.setEnabled(False)
self.conn_menu.port_set_spin.setEnabled(False)
case ThermostatConnectionState.DISCONNECTED:
self.graph_group.setEnabled(False)
self.report_group.setEnabled(False)
self.thermostat_settings.setEnabled(False)
self.conn_menu.host_set_line.setEnabled(True)
self.conn_menu.port_set_spin.setEnabled(True)
self.connect_btn.setText("Connect")
self.status_lbl.setText("Disconnected")
self.background_task_lbl.setText("Ready.")
self.loading_spinner.hide()
self.loading_spinner.stop()
self.thermostat_ctrl_menu.fan_pwm_warning.setPixmap(QtGui.QPixmap())
self.thermostat_ctrl_menu.fan_pwm_warning.setToolTip("")
self.channel_graphs.clear_graphs()
self.report_box.setChecked(False)
self._status(self.hw_rev_data)
self.thermostat.start_watching()
else:
self.status_lbl.setText("Disconnected")
self.background_task_lbl.setText("Ready.")
self.loading_spinner.hide()
self.loading_spinner.stop()
self.thermostat_ctrl_menu.fan_pwm_warning.setPixmap(QtGui.QPixmap())
self.thermostat_ctrl_menu.fan_pwm_warning.setToolTip("")
self.clear_graphs()
self.report_box.setChecked(False)
if not Thermostat.connecting or Thermostat.connected:
for ch in range(self.NUM_CHANNELS):
if self.autotuners.get_state(ch) != PIDAutotuneState.STATE_OFF:
if self.thermostat.connection_errored:
# Don't send any commands, just reset local state
self.autotuners.autotuners[ch].setOff()
else:
await self.autotuners.stop_pid_from_running(ch)
await self.autotuners.stop_pid_from_running(ch)
await self.thermostat.set_report_mode(False)
self.thermostat.stop_watching()
def _status(self, hw_rev_d: dict):
logging.debug(hw_rev_d)
self.status_lbl.setText(
f"Connected to Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['minor']}"
)
@pyqtSlot("QVariantMap")
def fan_update(self, fan_settings):
logging.debug(fan_settings)
if fan_settings is None:
return
with QSignalBlocker(self.thermostat_ctrl_menu.fan_power_slider):
self.thermostat_ctrl_menu.fan_power_slider.setValue(
fan_settings["fan_pwm"] or 100 # 0 = PWM off = full strength
)
with QSignalBlocker(self.thermostat_ctrl_menu.fan_auto_box):
self.thermostat_ctrl_menu.fan_auto_box.setChecked(fan_settings["auto_mode"])
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
self.thermostat_ctrl_menu.set_fan_pwm_warning()
@asyncSlot(int)
async def on_report_box_stateChanged(self, enabled):
await self.thermostat.set_report_mode(enabled)
@asyncClose
async def closeEvent(self, _event):
async def closeEvent(self, event):
try:
await self.thermostat.end_session()
await self.bail()
except:
pass
@asyncSlot()
async def on_connect_btn_clicked(self):
if (self._connecting_task is None) and (not self.thermostat.connected()):
self._connecting_task = asyncio.create_task(
self.thermostat.start_session(
host=self.conn_menu.host_set_line.text(),
port=self.conn_menu.port_set_spin.value(),
)
)
try:
await self._connecting_task
except (OSError, asyncio.CancelledError) as exc:
await self.thermostat.end_session()
if isinstance(exc, asyncio.CancelledError):
host, port = (
self.conn_menu.host_set_line.text(),
self.conn_menu.port_set_spin.value(),
)
try:
if not (self.thermostat.connecting() or self.thermostat.connected()):
self.status_lbl.setText("Connecting...")
self.connect_btn.setText("Stop")
self.conn_menu.host_set_line.setEnabled(False)
self.conn_menu.port_set_spin.setEnabled(False)
try:
await self.thermostat.start_session(host=host, port=port)
except StoppedConnecting:
return
raise
finally:
self._connecting_task = None
await self._on_connection_changed(True)
else:
await self.bail()
elif self._connecting_task is not None:
self._connecting_task.cancel()
else:
await self.thermostat.end_session()
# TODO: Remove asyncio.TimeoutError in Python 3.11
except (OSError, asyncio.TimeoutError):
try:
await self.bail()
except ConnectionResetError:
pass
@asyncSlot(int, PIDAutotuneState)
async def pid_autotune_handler(self, _ch, _state):
@asyncSlot()
async def bail(self):
await self._on_connection_changed(False)
await self.thermostat.end_session()
@asyncSlot(object, object)
async def send_command(self, param, changes):
"""Translates parameter tree changes into thermostat set_param calls"""
ch = param.channel
for inner_param, change, data in changes:
if change == "value":
if inner_param.opts.get("param", None) is not None:
if inner_param.opts.get("suffix", None) == "mA":
data /= 1000 # Given in mA
thermostat_param = inner_param.opts["param"]
if thermostat_param[1] == "ch":
thermostat_param[1] = ch
if inner_param.name() == "Postfilter Rate" and data is None:
set_param_args = (*thermostat_param[:2], "off")
else:
set_param_args = (*thermostat_param, data)
param.child(*param.childPath(inner_param)).setOpts(lock=True)
await self.thermostat.set_param(*set_param_args)
param.child(*param.childPath(inner_param)).setOpts(lock=False)
if inner_param.opts.get("pid_autotune", None) is not None:
auto_tuner_param = inner_param.opts["pid_autotune"][0]
if inner_param.opts["pid_autotune"][1] != "ch":
ch = inner_param.opts["pid_autotune"][1]
self.autotuners.set_params(auto_tuner_param, ch, data)
if inner_param.opts.get("activaters", None) is not None:
activater = inner_param.opts["activaters"][
inner_param.reverse[0].index(
data
) # ListParameter.reverse = list of codename values
]
if activater is not None:
if activater[1] == "ch":
activater[1] = ch
await self.thermostat.set_param(*activater)
@asyncSlot()
async def pid_auto_tune_request(self, ch=0):
match self.autotuners.get_state(ch):
case PIDAutotuneState.STATE_OFF | PIDAutotuneState.STATE_FAILED:
self.autotuners.load_params_and_set_ready(ch)
case (
PIDAutotuneState.STATE_READY
| PIDAutotuneState.STATE_RELAY_STEP_UP
| PIDAutotuneState.STATE_RELAY_STEP_DOWN
):
await self.autotuners.stop_pid_from_running(ch)
# To Update the UI elements
self.pid_autotune_handler([])
@asyncSlot(list)
async def pid_autotune_handler(self, _):
ch_tuning = []
for ch in range(self.NUM_CHANNELS):
match self.autotuners.get_state(ch):
case PIDAutotuneState.STATE_OFF:
self.ctrl_panel_view.change_params_title(
ch, ("PID Config", "PID Auto Tune", "Run"), "Run"
ch, ("pid", "pid_autotune", "run_pid"), "Run"
)
case (
PIDAutotuneState.STATE_READY
@ -230,21 +337,21 @@ class MainWindow(QtWidgets.QMainWindow):
| PIDAutotuneState.STATE_RELAY_STEP_DOWN
):
self.ctrl_panel_view.change_params_title(
ch, ("PID Config", "PID Auto Tune", "Run"), "Stop"
ch, ("pid", "pid_autotune", "run_pid"), "Stop"
)
ch_tuning.append(ch)
case PIDAutotuneState.STATE_SUCCEEDED:
self.info_box.display_info_box(
"PID Autotune Success",
f"Channel {ch} PID Config has been loaded to Thermostat. Regulating temperature.",
f"Channel {ch} PID Settings has been loaded to Thermostat. Regulating temperature.",
)
self.info_box.show()
case PIDAutotuneState.STATE_FAILED:
self.info_box.display_info_box(
"PID Autotune Failed",
f"Channel {ch} PID Autotune has failed.",
f"Channel {ch} PID Autotune on channel has failed.",
)
self.info_box.show()
@ -259,6 +366,74 @@ class MainWindow(QtWidgets.QMainWindow):
self.loading_spinner.start()
self.loading_spinner.show()
@asyncSlot(int)
async def fan_set_request(self, value):
assert self.thermostat.connected()
if self.thermostat_ctrl_menu.fan_auto_box.isChecked():
with QSignalBlocker(self.thermostat_ctrl_menu.fan_auto_box):
self.thermostat_ctrl_menu.fan_auto_box.setChecked(False)
await self.thermostat.set_fan(value)
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
self.thermostat_ctrl_menu.set_fan_pwm_warning()
@asyncSlot(int)
async def fan_auto_set_request(self, enabled):
assert self.thermostat.connected()
if enabled:
await self.thermostat.set_fan("auto")
self.fan_update(await self.thermostat.get_fan())
else:
await self.thermostat.set_fan(
self.thermostat_ctrl_menu.fan_power_slider.value()
)
@asyncSlot(int)
async def save_cfg_request(self, ch):
assert self.thermostat.connected()
await self.thermostat.save_cfg(str(ch))
@asyncSlot(int)
async def load_cfg_request(self, ch):
assert self.thermostat.connected()
await self.thermostat.load_cfg(str(ch))
@asyncSlot(bool)
async def dfu_request(self, _):
assert self.thermostat.connected()
await self._on_connection_changed(False)
await self.thermostat.dfu()
@asyncSlot(bool)
async def reset_request(self, _):
assert self.thermostat.connected()
await self._on_connection_changed(False)
await self.thermostat.reset()
await asyncio.sleep(0.1) # Wait for the reset to start
self.connect_btn.click() # Reconnect
@asyncSlot(bool)
async def net_settings_request(self, _):
assert self.thermostat.connected()
ipv4 = await self.thermostat.get_ipv4()
self.net_settings_input_diag = NetSettingsInputDiag(ipv4["addr"])
self.net_settings_input_diag.set_ipv4_act.connect(self.set_net_settings_request)
@asyncSlot(str)
async def set_net_settings_request(self, ipv4_settings):
assert self.thermostat.connected()
await self.thermostat.set_ipv4(ipv4_settings)
await self.thermostat.end_session()
await self._on_connection_changed(False)
async def coro_main():
args = get_argparser().parse_args()

View File

@ -1,5 +1,6 @@
use core::cmp::max_by;
use core::{cmp::max_by, marker::PhantomData};
use heapless::{consts::U2, Vec};
use num_traits::Zero;
use serde::{Serialize, Serializer};
use smoltcp::time::Instant;
use stm32f4xx_hal::hal;
@ -32,12 +33,24 @@ pub enum PinsAdcReadTarget {
pub const CHANNELS: usize = 2;
pub const R_SENSE: f64 = 0.05;
// as stated in the MAX1968 datasheet
pub const MAX_TEC_I: f64 = 3.0;
// From design specs
pub const MAX_TEC_I: ElectricCurrent = ElectricCurrent {
dimension: PhantomData,
units: PhantomData,
value: 2.0,
};
pub const MAX_TEC_V: ElectricPotential = ElectricPotential {
dimension: PhantomData,
units: PhantomData,
value: 4.0,
};
// DAC chip outputs 0-5v, which is then passed through a resistor dividor to provide 0-3v range
const DAC_OUT_V_MAX: f64 = 3.0;
const DAC_OUT_V_MAX: ElectricPotential = ElectricPotential {
dimension: PhantomData,
units: PhantomData,
value: 3.0,
};
// TODO: -pub
pub struct Channels {
channel0: Channel<Channel0>,
@ -128,7 +141,7 @@ impl Channels {
/// i_set DAC
fn set_dac(&mut self, channel: usize, voltage: ElectricPotential) -> ElectricPotential {
let value = ((voltage / ElectricPotential::new::<volt>(DAC_OUT_V_MAX)).get::<ratio>() * (ad5680::MAX_VALUE as f64)) as u32 ;
let value = ((voltage / DAC_OUT_V_MAX).get::<ratio>() * (ad5680::MAX_VALUE as f64)) as u32 ;
match channel {
0 => self.channel0.dac.set(value).unwrap(),
1 => self.channel1.dac.set(value).unwrap(),
@ -139,11 +152,7 @@ impl Channels {
}
pub fn set_i(&mut self, channel: usize, i_set: ElectricCurrent) -> ElectricCurrent {
// Silently clamp i_set
let i_ceiling = ElectricCurrent::new::<ampere>(MAX_TEC_I);
let i_floor = ElectricCurrent::new::<ampere>(-MAX_TEC_I);
let i_set = i_set.min(i_ceiling).max(i_floor);
let i_set = i_set.min(MAX_TEC_I).max(-MAX_TEC_I);
let vref_meas = match channel.into() {
0 => self.channel0.vref_meas,
1 => self.channel1.vref_meas,
@ -318,7 +327,7 @@ impl Channels {
best_error = error;
start_value = prev_value;
let vref = (value as f64 / ad5680::MAX_VALUE as f64) * ElectricPotential::new::<volt>(DAC_OUT_V_MAX);
let vref = (value as f64 / ad5680::MAX_VALUE as f64) * DAC_OUT_V_MAX;
match channel {
0 => self.channel0.vref_meas = vref,
1 => self.channel1.vref_meas = vref,
@ -378,22 +387,22 @@ impl Channels {
}
}
pub fn get_max_v(&mut self, channel: usize) -> ElectricPotential {
pub fn get_max_v(&mut self, channel: usize) -> (ElectricPotential, ElectricPotential) {
let max = 4.0 * ElectricPotential::new::<volt>(3.3);
let duty = self.get_pwm(channel, PwmPin::MaxV);
duty * max
(duty * max, MAX_TEC_V)
}
pub fn get_max_i_pos(&mut self, channel: usize) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0);
let duty = self.get_pwm(channel, PwmPin::MaxIPos);
(duty * max, max)
(duty * max, MAX_TEC_I)
}
pub fn get_max_i_neg(&mut self, channel: usize) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0);
let duty = self.get_pwm(channel, PwmPin::MaxINeg);
(duty * max, max)
(duty * max, MAX_TEC_I)
}
// Get current passing through TEC
@ -435,21 +444,21 @@ impl Channels {
pub fn set_max_v(&mut self, channel: usize, max_v: ElectricPotential) -> (ElectricPotential, ElectricPotential) {
let max = 4.0 * ElectricPotential::new::<volt>(3.3);
let duty = (max_v / max).get::<ratio>();
let duty = (max_v.min(MAX_TEC_V).max(ElectricPotential::zero()) / max).get::<ratio>();
let duty = self.set_pwm(channel, PwmPin::MaxV, duty);
(duty * max, max)
}
pub fn set_max_i_pos(&mut self, channel: usize, max_i_pos: ElectricCurrent) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0);
let duty = (max_i_pos / max).get::<ratio>();
let duty = (max_i_pos.min(MAX_TEC_I).max(ElectricCurrent::zero()) / max).get::<ratio>();
let duty = self.set_pwm(channel, PwmPin::MaxIPos, duty);
(duty * max, max)
}
pub fn set_max_i_neg(&mut self, channel: usize, max_i_neg: ElectricCurrent) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0);
let duty = (max_i_neg / max).get::<ratio>();
let duty = (max_i_neg.min(MAX_TEC_I).max(ElectricCurrent::zero()) / max).get::<ratio>();
let duty = self.set_pwm(channel, PwmPin::MaxINeg, duty);
(duty * max, max)
}
@ -509,8 +518,8 @@ impl Channels {
PwmSummary {
channel,
center: CenterPointJson(self.channel_state(channel).center.clone()),
i_set: (self.get_i(channel), ElectricCurrent::new::<ampere>(3.0)).into(),
max_v: (self.get_max_v(channel), ElectricPotential::new::<volt>(5.0)).into(),
i_set: (self.get_i(channel), MAX_TEC_I).into(),
max_v: self.get_max_v(channel).into(),
max_i_pos: self.get_max_i_pos(channel).into(),
max_i_neg: self.get_max_i_neg(channel).into(),
}

View File

@ -1,3 +1,4 @@
use num_traits::Zero;
use serde::{Serialize, Deserialize};
use uom::si::{
electric_potential::volt,
@ -18,6 +19,7 @@ pub struct ChannelConfig {
pid: pid::Parameters,
pid_target: f32,
pid_engaged: bool,
i_set: ElectricCurrent,
sh: steinhart_hart::Parameters,
pwm: PwmLimits,
/// uses variant `PostFilter::Invalid` instead of `None` to save space
@ -33,11 +35,17 @@ impl ChannelConfig {
.unwrap_or(PostFilter::Invalid);
let state = channels.channel_state(channel);
let i_set = if state.pid_engaged {
ElectricCurrent::zero()
} else {
state.i_set
};
ChannelConfig {
center: state.center.clone(),
pid: state.pid.parameters.clone(),
pid_target: state.pid.target as f32,
pid_engaged: state.pid_engaged,
i_set: i_set,
sh: state.sh.clone(),
pwm,
adc_postfilter,
@ -59,6 +67,7 @@ impl ChannelConfig {
adc_postfilter => Some(adc_postfilter),
};
let _ = channels.adc.set_postfilter(channel as u8, adc_postfilter);
let _ = channels.set_i(channel, self.i_set);
}
}
@ -71,7 +80,7 @@ struct PwmLimits {
impl PwmLimits {
pub fn new(channels: &mut Channels, channel: usize) -> Self {
let max_v = channels.get_max_v(channel);
let (max_v, _) = channels.get_max_v(channel);
let (max_i_pos, _) = channels.get_max_i_pos(channel);
let (max_i_neg, _) = channels.get_max_i_neg(channel);
PwmLimits {

View File

@ -54,7 +54,7 @@ impl FanCtrl {
pub fn cycle(&mut self, abs_max_tec_i: ElectricCurrent) {
self.abs_max_tec_i = abs_max_tec_i.get::<ampere>() as f32;
if self.fan_auto && self.hw_settings.fan_available {
let scaled_current = self.abs_max_tec_i / MAX_TEC_I as f32;
let scaled_current = self.abs_max_tec_i / MAX_TEC_I.get::<ampere>() as f32;
// do not limit upper bound, as it will be limited in the set_pwm()
let pwm = (MAX_USER_FAN_PWM * (scaled_current * (scaled_current * self.k_a + self.k_b) + self.k_c)) as u32;
self.set_pwm(pwm);

View File

@ -54,15 +54,13 @@ impl Controller {
// + x0 * (kp + ki + kd)
// - x1 * (kp + 2kd)
// + x2 * kd
// + kp * (u0 - u1)
// y0 = clip(y0', ymin, ymax)
pub fn update(&mut self, input: f64) -> f64 {
let mut output: f64 = self.y1 - self.target * f64::from(self.parameters.ki)
+ input * f64::from(self.parameters.kp + self.parameters.ki + self.parameters.kd)
- self.x1 * f64::from(self.parameters.kp + 2.0 * self.parameters.kd)
+ self.x2 * f64::from(self.parameters.kd)
+ f64::from(self.parameters.kp) * (self.target - self.u1);
+ self.x2 * f64::from(self.parameters.kd);
if output < self.parameters.output_min.into() {
output = self.parameters.output_min.into();
}