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": { "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": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1704290814, "lastModified": 1691421349,
"narHash": "sha256-LWvKHp7kGxk/GEtlrGYV68qIvPHkU9iToomNFGagixU=", "narHash": "sha256-RRJyX0CUrs4uW4gMhd/X4rcDG8PTgaaCQM5rXEJOx6g=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "70bdadeb94ffc8806c0570eb5c2695ad29f0e421", "rev": "011567f35433879aae5024fc6ec53f2a0568a6c4",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -34,8 +18,28 @@
}, },
"root": { "root": {
"inputs": { "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"; description = "Firmware for the Sinara 8451 Thermostat";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.05"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.05";
inputs.mozilla-overlay = { inputs.rust-overlay = {
url = "github:mozilla/nixpkgs-mozilla"; url = "github:oxalica/rust-overlay";
flake = false; inputs.nixpkgs.follows = "nixpkgs";
}; };
outputs = { self, nixpkgs, mozilla-overlay, }: outputs =
{
self,
nixpkgs,
rust-overlay,
}:
let let
pkgs = import nixpkgs { pkgs = import nixpkgs {
system = "x86_64-linux"; system = "x86_64-linux";
overlays = [ (import mozilla-overlay) ]; overlays = [ (import rust-overlay) ];
};
rustManifest = pkgs.fetchurl {
url =
"https://static.rust-lang.org/dist/2022-12-15/channel-rust-stable.toml";
hash = "sha256-S7epLlflwt0d1GZP44u5Xosgf6dRrmr8xxC+Ml2Pq7c=";
}; };
targets = [ "thumbv7em-none-eabihf" ]; rust = pkgs.rust-bin.stable."1.66.0".default.override {
rustChannelOfTargets = _channel: _date: targets: extensions = [ "rust-src" ];
(pkgs.lib.rustLib.fromManifestFile rustManifest { targets = [ "thumbv7em-none-eabihf" ];
inherit (pkgs) stdenv lib fetchurl patchelf; };
}).rust.override { rustPlatform = pkgs.makeRustPlatform {
inherit targets;
extensions = [ "rust-src" ];
};
rust = rustChannelOfTargets "stable" null targets;
rustPlatform = pkgs.recurseIntoAttrs (pkgs.makeRustPlatform {
rustc = rust; rustc = rust;
cargo = rust; cargo = rust;
}); };
thermostat = rustPlatform.buildRustPackage { thermostat = rustPlatform.buildRustPackage {
name = "thermostat"; name = "thermostat";
version = "0.0.0"; version = "0.0.0";
@ -40,8 +36,7 @@
cargoLock = { cargoLock = {
lockFile = ./Cargo.lock; lockFile = ./Cargo.lock;
outputHashes = { outputHashes = {
"stm32-eth-0.2.0" = "stm32-eth-0.2.0" = "sha256-48RpZgagUqgVeKm7GXdk3Oo0v19ScF9Uby0nTFlve2o=";
"sha256-48RpZgagUqgVeKm7GXdk3Oo0v19ScF9Uby0nTFlve2o=";
}; };
}; };
@ -82,7 +77,10 @@
inherit pname version; inherit pname version;
hash = "sha256-WBCNhBHHBU4IQdi3ke6F4QH8KWubNZwOAd3jipj/Ks4="; hash = "sha256-WBCNhBHHBU4IQdi3ke6F4QH8KWubNZwOAd3jipj/Ks4=";
}; };
propagatedBuildInputs = with pkgs.python3Packages; [ numpy pyqt6 ]; propagatedBuildInputs = with pkgs.python3Packages; [
numpy
pyqt6
];
}; };
qtextras = pkgs.python3Packages.buildPythonPackage rec { qtextras = pkgs.python3Packages.buildPythonPackage rec {
@ -110,22 +108,12 @@
hash = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A="; hash = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A=";
}; };
buildInputs = [ pkgs.python3Packages.poetry-core ]; 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 { thermostat_gui = pkgs.python3Packages.buildPythonPackage {
pname = "thermostat_gui"; pname = "thermostat_gui";
version = "0.0.0"; version = "0.0.0";
@ -133,7 +121,8 @@
src = "${self}/pytec"; src = "${self}/pytec";
nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ]; nativeBuildInputs = [ pkgs.qt6.wrapQtAppsHook ];
propagatedBuildInputs = [ pkgs.qt6.qtbase ] propagatedBuildInputs =
[ pkgs.qt6.qtbase ]
++ (with pkgs.python3Packages; [ ++ (with pkgs.python3Packages; [
pyqtgraph pyqtgraph
pyqt6 pyqt6
@ -147,22 +136,31 @@
wrapQtApp "$out/bin/tec_qt" wrapQtApp "$out/bin/tec_qt"
''; '';
}; };
in { in
packages.x86_64-linux = { inherit thermostat thermostat_gui; }; {
packages.x86_64-linux = {
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt; inherit thermostat thermostat_gui;
default = thermostat;
};
apps.x86_64-linux.thermostat_gui = { apps.x86_64-linux.thermostat_gui = {
type = "app"; type = "app";
program = "${self.packages.x86_64-linux.thermostat_gui}/bin/tec_qt"; 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"; name = "thermostat-dev-shell";
buildInputs = with pkgs; packages =
[ rust openocd dfu-util pytec-dev-wrappers ] with pkgs;
[
rust
openocd
dfu-util
]
++ (with python3Packages; [ ++ (with python3Packages; [
numpy numpy
matplotlib matplotlib
@ -173,10 +171,6 @@
pglive pglive
qtextras qtextras
]); ]);
shellHook = ''
export PYTHONPATH=`pwd`/pytec:$PYTHONPATH
'';
}; };
defaultPackage.x86_64-linux = thermostat;
}; };
} }

View File

@ -1,4 +1,4 @@
graft examples graft examples
include pytec/gui/resources/artiq.ico include pytec/gui/resources/artiq.ico
include pytec/gui/view/param_tree.json 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(): async def main():
tec = AsyncioClient() 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) await tec.set_param("s-h", 1, "t0", 20)
print(await tec.get_pwm()) print(await tec.get_pwm())
print(await tec.get_pid()) 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" tec_qt = "tec_qt:main"
[tool.setuptools] [tool.setuptools]
packages.find = {} packages.find = {} # Use setuptools custom discovery, package directory structure isn't standard
py-modules = ["autotune", "plot", "tec_qt"] py-modules = ["autotune", "plot", "tec_qt"]
[tool.pylint.format] [tool.pylint.format]

View File

@ -7,29 +7,56 @@ class CommandError(Exception):
pass pass
class StoppedConnecting(Exception):
pass
class AsyncioClient: class AsyncioClient:
def __init__(self): def __init__(self):
self._reader = None self._reader = None
self._writer = None self._writer = None
self._connecting_task = None
self._command_lock = asyncio.Lock() self._command_lock = asyncio.Lock()
self._report_mode_on = False self._report_mode_on = False
self.timeout = None
async def connect(self, host="192.168.1.26", port=23): async def start_session(self, host="192.168.1.26", port=23, timeout=None):
"""Connect to Thermostat at specified host and port. """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:: Example::
client = AsyncioClient() 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() await self._check_zero_limits()
def connecting(self):
"""Returns True if client is connecting"""
return self._connecting_task is not None
def connected(self): def connected(self):
"""Returns True if client is connected""" """Returns True if client is connected"""
return self._writer is not None return self._writer is not None
async def disconnect(self): async def end_session(self):
"""Disconnect from the Thermostat""" """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: if self._writer is None:
return return
@ -53,7 +80,9 @@ class AsyncioClient:
async def _read_line(self): async def _read_line(self):
# read 1 line # 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") return chunk.decode("utf-8", errors="ignore")
async def _read_write(self, command): async def _read_write(self, command):
@ -67,7 +96,7 @@ class AsyncioClient:
line = await self._read_write(command) line = await self._read_write(command)
response = json.loads(line) response = json.loads(line)
logging.debug("%s: %s", command, response) logging.debug(f"{command}: {response}")
if "error" in response: if "error" in response:
raise CommandError(response["error"]) raise CommandError(response["error"])
return response return response
@ -218,8 +247,6 @@ class AsyncioClient:
async def save_config(self, channel=""): async def save_config(self, channel=""):
"""Save current configuration to EEPROM""" """Save current configuration to EEPROM"""
await self._command("save", str(channel)) await self._command("save", str(channel))
if channel == "":
await self._read_line() # Read the extra {}
async def load_config(self, channel=""): async def load_config(self, channel=""):
"""Load current configuration from EEPROM""" """Load current configuration from EEPROM"""
@ -240,7 +267,7 @@ class AsyncioClient:
self._writer.write("reset\n".encode("utf-8")) self._writer.write("reset\n".encode("utf-8"))
await self._writer.drain() await self._writer.drain()
await self.disconnect() await self.end_session()
async def dfu(self): async def dfu(self):
"""Put the Thermostat in DFU update mode """Put the Thermostat in DFU update mode
@ -253,7 +280,7 @@ class AsyncioClient:
self._writer.write("dfu\n".encode("utf-8")) self._writer.write("dfu\n".encode("utf-8"))
await self._writer.drain() await self._writer.drain()
await self.disconnect() await self.end_session()
async def ipv4(self): async def ipv4(self):
"""Get the IPv4 settings of the Thermostat""" """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 qasync import asyncSlot
from autotune import PIDAutotuneState, PIDAutotune from autotune import PIDAutotuneState, PIDAutotune
class PIDAutoTuner(QObject): class PIDAutoTuner(QObject):
autotune_state_changed = pyqtSignal(int, PIDAutotuneState)
def __init__(self, parent, thermostat, num_of_channel): def __init__(self, parent, thermostat, num_of_channel):
super().__init__(parent) super().__init__(parent)
self._thermostat = thermostat 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.autotuners = [PIDAutotune(25) for _ in range(num_of_channel)]
self.target_temp = [20.0 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)] self.test_current = [1.0 for _ in range(num_of_channel)]
@ -39,11 +34,9 @@ class PIDAutoTuner(QObject):
self.lookback[ch], self.lookback[ch],
) )
self.autotuners[ch].setReady() self.autotuners[ch].setReady()
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
async def stop_pid_from_running(self, ch): async def stop_pid_from_running(self, ch):
self.autotuners[ch].setOff() self.autotuners[ch].setOff()
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
await self._thermostat.set_param("pwm", ch, "i_set", 0) await self._thermostat.set_param("pwm", ch, "i_set", 0)
@asyncSlot(list) @asyncSlot(list)
@ -69,7 +62,6 @@ class PIDAutoTuner(QObject):
case PIDAutotuneState.STATE_SUCCEEDED: case PIDAutotuneState.STATE_SUCCEEDED:
kp, ki, kd = self.autotuners[ch].get_tec_pid() kp, ki, kd = self.autotuners[ch].get_tec_pid()
self.autotuners[ch].setOff() 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, "kp", kp)
await self._thermostat.set_param("pid", ch, "ki", ki) await self._thermostat.set_param("pid", ch, "ki", ki)
@ -81,5 +73,4 @@ class PIDAutoTuner(QObject):
) )
case PIDAutotuneState.STATE_FAILED: case PIDAutotuneState.STATE_FAILED:
self.autotuners[ch].setOff() self.autotuners[ch].setOff()
self.autotune_state_changed.emit(ch, self.autotuners[ch].state())
await self._thermostat.set_param("pwm", ch, "i_set", 0) 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 from pytec.gui.model.property import Property, PropertyMeta
import asyncio import asyncio
import logging import logging
from enum import Enum
from pytec.aioclient import AsyncioClient from pytec.aioclient import AsyncioClient
class ThermostatConnectionState(Enum):
DISCONNECTED = "disconnected"
CONNECTING = "connecting"
CONNECTED = "connected"
class Thermostat(QObject, metaclass=PropertyMeta): class Thermostat(QObject, metaclass=PropertyMeta):
hw_rev = Property(dict) hw_rev = Property(dict)
fan = Property(dict) fan = Property(dict)
@ -24,7 +17,6 @@ class Thermostat(QObject, metaclass=PropertyMeta):
report = Property(list) report = Property(list)
info_box_trigger = pyqtSignal(str, str) info_box_trigger = pyqtSignal(str, str)
connection_error = pyqtSignal() connection_error = pyqtSignal()
connection_state_changed = pyqtSignal(ThermostatConnectionState)
def __init__(self, parent, update_s): def __init__(self, parent, update_s):
self._update_s = update_s self._update_s = update_s
@ -32,63 +24,51 @@ class Thermostat(QObject, metaclass=PropertyMeta):
self._watch_task = None self._watch_task = None
self._report_mode_task = None self._report_mode_task = None
self._poll_for_report = True self._poll_for_report = True
self._update_params_task = None
self.connection_errored = False
super().__init__(parent) super().__init__(parent)
async def start_session(self, host, port): async def start_session(self, host, port):
self.connection_state_changed.emit(ThermostatConnectionState.CONNECTING) await self._client.start_session(host, port, timeout=5)
await self._client.connect(host, port)
await self.get_hw_rev()
self.connection_state_changed.emit(ThermostatConnectionState.CONNECTED)
self.start_watching()
async def run(self): async def run(self):
self._update_params_task = asyncio.create_task(self.update_params()) self.task = asyncio.create_task(self.update_params())
while True: while True:
if self._update_params_task.done(): if self.task.done():
try: if self.task.exception() is not None:
self._update_params_task.result() try:
except OSError: raise self.task.exception()
logging.error( except asyncio.TimeoutError:
"Encountered an error while polling for information from Thermostat.", logging.error(
exc_info=True, "Encountered an error while updating parameter tree.",
) exc_info=True,
self.connection_error.emit() )
return self.connection_error.emit()
self._update_params_task = asyncio.create_task(self.update_params()) return
_ = self.task.result()
self.task = asyncio.create_task(self.update_params())
await asyncio.sleep(self._update_s) await asyncio.sleep(self._update_s)
@pyqtSlot()
def timed_out(self):
self.connection_errored = True
async def get_hw_rev(self): async def get_hw_rev(self):
self.hw_rev = await self._client.hw_rev() self.hw_rev = await self._client.hw_rev()
return self.hw_rev return self.hw_rev
async def update_params(self): async def update_params(self):
fan_task = asyncio.create_task(self._client.get_fan()) self.fan = await self._client.get_fan()
pwm_task = asyncio.create_task(self._client.get_pwm()) self.pwm = await 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
if self._poll_for_report: if self._poll_for_report:
self.report = await report_task self.report = await self._client.report()
self.interval = [ self.interval = [
self.report[i]["interval"] for i in range(len(self.report)) self.report[i]["interval"] for i in range(len(self.report))
] ]
self.pid = await pid_task self.pid = await self._client.get_pid()
self.thermistor = await thermistor_task self.thermistor = await self._client.get_steinhart_hart()
self.postfilter = await postfilter_task self.postfilter = await self._client.get_postfilter()
def connected(self): def connected(self):
return self._client.connected() return self._client.connected()
def connecting(self):
return self._client.connecting()
def start_watching(self): def start_watching(self):
self._watch_task = asyncio.create_task(self.run()) self._watch_task = asyncio.create_task(self.run())
@ -98,8 +78,8 @@ class Thermostat(QObject, metaclass=PropertyMeta):
await self.set_report_mode(False) await self.set_report_mode(False)
self._watch_task.cancel() self._watch_task.cancel()
self._watch_task = None self._watch_task = None
self._update_params_task.cancel() self.task.cancel()
self._update_params_task = None self.task = None
async def set_report_mode(self, enabled: bool): async def set_report_mode(self, enabled: bool):
self._poll_for_report = not enabled 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)) self.report[i]["interval"] for i in range(len(self.report))
] ]
@asyncSlot()
async def end_session(self): async def end_session(self):
await self.set_report_mode(False) await self._client.end_session()
self.stop_watching()
await self._client.disconnect()
self.connection_state_changed.emit(ThermostatConnectionState.DISCONNECTED)
self.connection_errored = False
async def set_ipv4(self, ipv4): async def set_ipv4(self, ipv4):
await self._client.set_param("ipv4", ipv4) await self._client.set_param("ipv4", ipv4)
@ -130,12 +105,18 @@ class Thermostat(QObject, metaclass=PropertyMeta):
return await self._client.ipv4() return await self._client.ipv4()
@asyncSlot() @asyncSlot()
async def save_cfg(self, ch=""): async def save_cfg(self, ch):
await self._client.save_config(ch) await self._client.save_config(ch)
self.info_box_trigger.emit(
"Settings loaded", f"Channel {ch} Settings has been saved to flash."
)
@asyncSlot() @asyncSlot()
async def load_cfg(self, ch=""): async def load_cfg(self, ch):
await self._client.load_config(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): async def dfu(self):
await self._client.dfu() await self._client.dfu()

View File

@ -1,12 +1,9 @@
from functools import partial
from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot from PyQt6.QtCore import pyqtSignal, QObject, QSignalBlocker, pyqtSlot
import pyqtgraph.parametertree.parameterTypes as pTypes import pyqtgraph.parametertree.parameterTypes as pTypes
from pyqtgraph.parametertree import ( from pyqtgraph.parametertree import (
Parameter, Parameter,
registerParameterType, registerParameterType,
) )
from qasync import asyncSlot
from autotune import PIDAutotuneState
class MutexParameter(pTypes.ListParameter): class MutexParameter(pTypes.ListParameter):
@ -45,23 +42,26 @@ class MutexParameter(pTypes.ListParameter):
registerParameterType("mutex", MutexParameter) 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): class CtrlPanel(QObject):
set_zero_limits_warning_sig = pyqtSignal(list) set_zero_limits_warning_sig = pyqtSignal(list)
def __init__( def __init__(
self, self,
thermostat,
autotuners,
info_box,
trees_ui, trees_ui,
param_tree, param_tree,
sigTreeStateChanged_handle,
sigActivated_handles,
parent=None, parent=None,
): ):
super().__init__(parent) super().__init__(parent)
self.thermostat = thermostat
self.autotuners = autotuners
self.info_box = info_box
self.trees_ui = trees_ui self.trees_ui = trees_ui
self.NUM_CHANNELS = len(trees_ui) self.NUM_CHANNELS = len(trees_ui)
@ -84,23 +84,12 @@ class CtrlPanel(QObject):
tree.setHeaderHidden(True) tree.setHeaderHidden(True)
tree.setParameters(self.params[i], showTop=False) tree.setParameters(self.params[i], showTop=False)
self.params[i].setValue = self._setValue 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( set_tree_label_tips(tree)
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))
self.thermostat.pid_update.connect(self.update_pid) for handle in sigActivated_handles[i]:
self.thermostat.report_update.connect(self.update_report) self.params[i].child(*handle[0]).sigActivated.connect(handle[1])
self.thermostat.thermistor_update.connect(self.update_thermistor)
self.thermostat.pwm_update.connect(self.update_pwm)
self.thermostat.postfilter_update.connect(self.update_postfilter)
def _setValue(self, value, blockSignal=None): def _setValue(self, value, blockSignal=None):
""" """
@ -131,66 +120,28 @@ class CtrlPanel(QObject):
def change_params_title(self, channel, path, title): def change_params_title(self, channel, path, title):
self.params[channel].child(*path).setOpts(title=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") @pyqtSlot("QVariantList")
def update_pid(self, pid_settings): def update_pid(self, pid_settings):
for settings in pid_settings: for settings in pid_settings:
channel = settings["channel"] channel = settings["channel"]
with QSignalBlocker(self.params[channel]): with QSignalBlocker(self.params[channel]):
self.params[channel].child("PID Config", "Kp").setValue( self.params[channel].child("pid", "kp").setValue(
settings["parameters"]["kp"] settings["parameters"]["kp"]
) )
self.params[channel].child("PID Config", "Ki").setValue( self.params[channel].child("pid", "ki").setValue(
settings["parameters"]["ki"] settings["parameters"]["ki"]
) )
self.params[channel].child("PID Config", "Kd").setValue( self.params[channel].child("pid", "kd").setValue(
settings["parameters"]["kd"] settings["parameters"]["kd"]
) )
self.params[channel].child( self.params[channel].child(
"PID Config", "PID Output Clamping", "Minimum" "pid", "pid_output_clamping", "output_min"
).setValue(settings["parameters"]["output_min"] * 1000) ).setValue(settings["parameters"]["output_min"] * 1000)
self.params[channel].child( self.params[channel].child(
"PID Config", "PID Output Clamping", "Maximum" "pid", "pid_output_clamping", "output_max"
).setValue(settings["parameters"]["output_max"] * 1000) ).setValue(settings["parameters"]["output_max"] * 1000)
self.params[channel].child( self.params[channel].child(
"Output Config", "Control Method", "Set Temperature" "output", "control_method", "set_temperature"
).setValue(settings["target"]) ).setValue(settings["target"])
@pyqtSlot("QVariantList") @pyqtSlot("QVariantList")
@ -198,19 +149,19 @@ class CtrlPanel(QObject):
for settings in report_data: for settings in report_data:
channel = settings["channel"] channel = settings["channel"]
with QSignalBlocker(self.params[channel]): with QSignalBlocker(self.params[channel]):
self.params[channel].child("Output Config", "Control Method").setValue( self.params[channel].child("output", "control_method").setValue(
"Temperature PID" if settings["pid_engaged"] else "Constant Current" "temperature_pid" if settings["pid_engaged"] else "constant_current"
) )
self.params[channel].child( self.params[channel].child(
"Output Config", "Control Method", "Set Current" "output", "control_method", "set_current"
).setValue(settings["i_set"] * 1000) ).setValue(settings["i_set"] * 1000)
if settings["temperature"] is not None: if settings["temperature"] is not None:
self.params[channel].child("Temperature").setValue( self.params[channel].child("temperature").setValue(
settings["temperature"] settings["temperature"]
) )
if settings["tec_i"] is not None: if settings["tec_i"] is not None:
self.params[channel].child("Current through TEC").setValue( self.params[channel].child("current").setValue(
settings["tec_i"] * 1000 settings["tec_i"]
) )
@pyqtSlot("QVariantList") @pyqtSlot("QVariantList")
@ -218,13 +169,13 @@ class CtrlPanel(QObject):
for sh_param in sh_data: for sh_param in sh_data:
channel = sh_param["channel"] channel = sh_param["channel"]
with QSignalBlocker(self.params[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 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"] sh_param["params"]["r0"]
) )
self.params[channel].child("Thermistor Config", "B").setValue( self.params[channel].child("thermistor", "b").setValue(
sh_param["params"]["b"] sh_param["params"]["b"]
) )
@ -235,15 +186,15 @@ class CtrlPanel(QObject):
for pwm_params in pwm_data: for pwm_params in pwm_data:
channel = pwm_params["channel"] channel = pwm_params["channel"]
with QSignalBlocker(self.params[channel]): with QSignalBlocker(self.params[channel]):
self.params[channel].child( self.params[channel].child("output", "limits", "max_v").setValue(
"Output Config", "Limits", "Max Voltage Difference" pwm_params["max_v"]["value"]
).setValue(pwm_params["max_v"]["value"]) )
self.params[channel].child( self.params[channel].child("output", "limits", "max_i_pos").setValue(
"Output Config", "Limits", "Max Cooling Current" pwm_params["max_i_pos"]["value"] * 1000
).setValue(pwm_params["max_i_pos"]["value"] * 1000) )
self.params[channel].child( self.params[channel].child("output", "limits", "max_i_neg").setValue(
"Output Config", "Limits", "Max Heating Current" pwm_params["max_i_neg"]["value"] * 1000
).setValue(pwm_params["max_i_neg"]["value"] * 1000) )
for limit in "max_i_pos", "max_i_neg", "max_v": for limit in "max_i_pos", "max_i_neg", "max_v":
if pwm_params[limit]["value"] == 0.0: if pwm_params[limit]["value"] == 0.0:
@ -255,39 +206,6 @@ class CtrlPanel(QObject):
for postfilter_params in postfilter_data: for postfilter_params in postfilter_data:
channel = postfilter_params["channel"] channel = postfilter_params["channel"]
with QSignalBlocker(self.params[channel]): with QSignalBlocker(self.params[channel]):
self.params[channel].child( self.params[channel].child("thermistor", "postfilter_rate").setValue(
"Thermistor Config", "Postfilter Rate" postfilter_params["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)

View File

@ -10,12 +10,8 @@ pg.setConfigOptions(antialias=True)
class LiveDataPlotter(QObject): class LiveDataPlotter(QObject):
def __init__(self, thermostat, live_plots): def __init__(self, live_plots):
super().__init__() 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.NUM_CHANNELS = len(live_plots)
self.graphs = [] self.graphs = []

View File

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

View File

@ -1,20 +1,24 @@
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
from qasync import asyncSlot
from pytec.gui.view.net_settings_input_diag import NetSettingsInputDiag
class ThermostatCtrlMenu(QtWidgets.QMenu): 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__() super().__init__()
self._thermostat = thermostat
self._info_box = info_box
self._style = style self._style = style
self.setTitle("Thermostat settings") self.setTitle("Thermostat settings")
self.hw_rev_data = dict() self.hw_rev_data = dict()
self._thermostat.hw_rev_update.connect(self.hw_rev)
self.fan_group = QtWidgets.QWidget() self.fan_group = QtWidgets.QWidget()
self.fan_group.setEnabled(False) self.fan_group.setEnabled(False)
@ -41,9 +45,8 @@ class ThermostatCtrlMenu(QtWidgets.QMenu):
self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0)) self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0))
self.fan_layout.addWidget(self.fan_pwm_warning) self.fan_layout.addWidget(self.fan_pwm_warning)
self.fan_power_slider.valueChanged.connect(self.fan_set_request) self.fan_power_slider.valueChanged.connect(self.fan_set_act)
self.fan_auto_box.stateChanged.connect(self.fan_auto_set_request) self.fan_auto_box.stateChanged.connect(self.fan_auto_set_act)
self._thermostat.fan_update.connect(self.fan_update)
self.fan_lbl.setToolTip("Adjust the fan") self.fan_lbl.setToolTip("Adjust the fan")
self.fan_lbl.setText("Fan:") self.fan_lbl.setText("Fan:")
@ -55,36 +58,40 @@ class ThermostatCtrlMenu(QtWidgets.QMenu):
self.fan = fan self.fan = fan
self.actionReset = QtGui.QAction("Reset Thermostat", self) 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.addAction(self.actionReset)
self.actionEnter_DFU_Mode = QtGui.QAction("Enter DFU Mode", self) 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.addAction(self.actionEnter_DFU_Mode)
self.actionnet_settings_input_diag = QtGui.QAction("Set IPV4 Settings", self) 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) self.addAction(self.actionnet_settings_input_diag)
@asyncSlot(bool) @pyqtSlot(bool)
async def load(_): def load(_):
await self._thermostat.load_cfg() self.load_cfg_act.emit(0)
self.load_cfg_act.emit(1)
self._info_box.display_info_box( loaded = QtWidgets.QMessageBox(self)
"Config loaded", "All channel configs have been loaded from flash." 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 = QtGui.QAction("Load Config", self)
self.actionLoad_all_configs.triggered.connect(load) self.actionLoad_all_configs.triggered.connect(load)
self.addAction(self.actionLoad_all_configs) self.addAction(self.actionLoad_all_configs)
@asyncSlot(bool) @pyqtSlot(bool)
async def save(_): def save(_):
await self._thermostat.save_cfg() self.save_cfg_act.emit(0)
self.save_cfg_act.emit(1)
self._info_box.display_info_box( saved = QtWidgets.QMessageBox(self)
"Config saved", "All channel configs have been saved to flash." 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 = QtGui.QAction("Save Config", self)
self.actionSave_all_configs.triggered.connect(save) self.actionSave_all_configs.triggered.connect(save)
@ -120,18 +127,6 @@ class ThermostatCtrlMenu(QtWidgets.QMenu):
self.actionAbout_Thermostat.triggered.connect(about_thermostat) self.actionAbout_Thermostat.triggered.connect(about_thermostat)
self.addAction(self.actionAbout_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): def set_fan_pwm_warning(self):
if self.fan_power_slider.value() != 100: if self.fan_power_slider.value() != 100:
pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning") pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning")
@ -148,55 +143,3 @@ class ThermostatCtrlMenu(QtWidgets.QMenu):
def hw_rev(self, hw_rev): def hw_rev(self, hw_rev):
self.hw_rev_data = hw_rev self.hw_rev_data = hw_rev
self.fan_group.setEnabled(self.hw_rev_data["settings"]["fan_available"]) 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.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.thermostat_ctrl_menu import ThermostatCtrlMenu
from pytec.gui.view.conn_menu import ConnMenu from pytec.gui.view.conn_menu import ConnMenu
from pytec.gui.view.plot_options_menu import PlotOptionsMenu 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.ctrl_panel import CtrlPanel
from pytec.gui.view.info_box import InfoBox from pytec.gui.view.info_box import InfoBox
from pytec.gui.model.pid_autotuner import PIDAutoTuner 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 import json
from autotune import PIDAutotuneState from autotune import PIDAutotuneState
from qasync import asyncSlot, asyncClose from qasync import asyncSlot, asyncClose
import qasync import qasync
from pytec.aioclient import StoppedConnecting
import asyncio import asyncio
import logging import logging
import argparse import argparse
@ -28,9 +30,9 @@ def get_argparser():
"--connect", "--connect",
default=None, default=None,
action="store_true", 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("PORT", metavar="port", default=None, nargs="?")
parser.add_argument( parser.add_argument(
"-l", "-l",
@ -58,38 +60,47 @@ class MainWindow(QtWidgets.QMainWindow):
ui_file_path = importlib.resources.files("pytec.gui.view").joinpath("tec_qt.ui") ui_file_path = importlib.resources.files("pytec.gui.view").joinpath("tec_qt.ui")
uic.loadUi(ui_file_path, self) uic.loadUi(ui_file_path, self)
self.hw_rev_data = None
self.info_box = InfoBox() self.info_box = InfoBox()
self.thermostat = Thermostat( self.thermostat = Thermostat(
self, self.report_refresh_spin.value() self, self.report_refresh_spin.value()
) )
self._connecting_task = None
def handle_connection_error(): def handle_connection_error():
logging.error("Client connection error, disconnecting")
self.info_box.display_info_box( self.info_box.display_info_box(
"Connection Error", "Thermostat connection lost. Is it unplugged?" "Connection Error", "Thermostat connection lost. Is it unplugged?"
) )
self.bail()
self.thermostat.connection_error.connect(handle_connection_error) 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 = PIDAutoTuner(self, self.thermostat, 2)
self.autotuners.autotune_state_changed.connect(self.pid_autotune_handler)
def get_ctrl_panel_config(args): 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"] 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.ctrl_panel_view = CtrlPanel(
self.thermostat,
self.autotuners,
self.info_box,
[self.ch0_tree, self.ch1_tree], [self.ch0_tree, self.ch1_tree],
get_ctrl_panel_config(args), get_ctrl_panel_config(args),
self.send_command,
param_tree_sigActivated_handles,
) )
self.zero_limits_warning = ZeroLimitsWarningView( self.zero_limits_warning = ZeroLimitsWarningView(
@ -99,28 +110,55 @@ class MainWindow(QtWidgets.QMainWindow):
self.zero_limits_warning.set_limits_warning 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( self.report_apply_btn.clicked.connect(
lambda: self.thermostat.set_update_s(self.report_refresh_spin.value()) lambda: self.thermostat.set_update_s(self.report_refresh_spin.value())
) )
self.channel_graphs = LiveDataPlotter( self.channel_graphs = LiveDataPlotter(
self.thermostat,
[ [
[getattr(self, f"ch{ch}_t_graph"), getattr(self, f"ch{ch}_i_graph")] [getattr(self, f"ch{ch}_t_graph"), getattr(self, f"ch{ch}_i_graph")]
for ch in range(self.NUM_CHANNELS) 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.plot_settings.setMenu(self.plot_options_menu)
self.conn_menu = ConnMenu() self.conn_menu = ConnMenu()
self.connect_btn.setMenu(self.conn_menu) self.connect_btn.setMenu(self.conn_menu)
self.thermostat_ctrl_menu = ThermostatCtrlMenu( self.thermostat_ctrl_menu = ThermostatCtrlMenu(self.style())
self.thermostat, self.info_box, 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.thermostat_settings.setMenu(self.thermostat_ctrl_menu)
self.loading_spinner.hide() self.loading_spinner.hide()
@ -132,97 +170,166 @@ class MainWindow(QtWidgets.QMainWindow):
self.port_set_spin.setValue(int(args.PORT)) self.port_set_spin.setValue(int(args.PORT))
self.connect_btn.click() self.connect_btn.click()
@asyncSlot(ThermostatConnectionState) def clear_graphs(self):
self.channel_graphs.clear_graphs()
async def _on_connection_changed(self, result): async def _on_connection_changed(self, result):
match result: self.graph_group.setEnabled(result)
case ThermostatConnectionState.CONNECTED: self.report_group.setEnabled(result)
self.graph_group.setEnabled(True) self.thermostat_settings.setEnabled(result)
self.report_group.setEnabled(True)
self.thermostat_settings.setEnabled(True)
self.conn_menu.host_set_line.setEnabled(False) self.conn_menu.host_set_line.setEnabled(not result)
self.conn_menu.port_set_spin.setEnabled(False) self.conn_menu.port_set_spin.setEnabled(not result)
self.connect_btn.setText("Disconnect") 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(self.hw_rev_data)
self.status_lbl.setText("Connecting...") self.thermostat.start_watching()
self.connect_btn.setText("Stop") else:
self.conn_menu.host_set_line.setEnabled(False) self.status_lbl.setText("Disconnected")
self.conn_menu.port_set_spin.setEnabled(False) self.background_task_lbl.setText("Ready.")
self.loading_spinner.hide()
case ThermostatConnectionState.DISCONNECTED: self.loading_spinner.stop()
self.graph_group.setEnabled(False) self.thermostat_ctrl_menu.fan_pwm_warning.setPixmap(QtGui.QPixmap())
self.report_group.setEnabled(False) self.thermostat_ctrl_menu.fan_pwm_warning.setToolTip("")
self.thermostat_settings.setEnabled(False) self.clear_graphs()
self.report_box.setChecked(False)
self.conn_menu.host_set_line.setEnabled(True) if not Thermostat.connecting or Thermostat.connected:
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)
for ch in range(self.NUM_CHANNELS): for ch in range(self.NUM_CHANNELS):
if self.autotuners.get_state(ch) != PIDAutotuneState.STATE_OFF: if self.autotuners.get_state(ch) != PIDAutotuneState.STATE_OFF:
if self.thermostat.connection_errored: await self.autotuners.stop_pid_from_running(ch)
# Don't send any commands, just reset local state await self.thermostat.set_report_mode(False)
self.autotuners.autotuners[ch].setOff() self.thermostat.stop_watching()
else:
await self.autotuners.stop_pid_from_running(ch)
def _status(self, hw_rev_d: dict): def _status(self, hw_rev_d: dict):
logging.debug(hw_rev_d)
self.status_lbl.setText( self.status_lbl.setText(
f"Connected to Thermostat v{hw_rev_d['rev']['major']}.{hw_rev_d['rev']['minor']}" 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) @asyncSlot(int)
async def on_report_box_stateChanged(self, enabled): async def on_report_box_stateChanged(self, enabled):
await self.thermostat.set_report_mode(enabled) await self.thermostat.set_report_mode(enabled)
@asyncClose @asyncClose
async def closeEvent(self, _event): async def closeEvent(self, event):
try: try:
await self.thermostat.end_session() await self.bail()
except: except:
pass pass
@asyncSlot() @asyncSlot()
async def on_connect_btn_clicked(self): async def on_connect_btn_clicked(self):
if (self._connecting_task is None) and (not self.thermostat.connected()): host, port = (
self._connecting_task = asyncio.create_task( self.conn_menu.host_set_line.text(),
self.thermostat.start_session( self.conn_menu.port_set_spin.value(),
host=self.conn_menu.host_set_line.text(), )
port=self.conn_menu.port_set_spin.value(), try:
) if not (self.thermostat.connecting() or self.thermostat.connected()):
) self.status_lbl.setText("Connecting...")
try: self.connect_btn.setText("Stop")
await self._connecting_task self.conn_menu.host_set_line.setEnabled(False)
except (OSError, asyncio.CancelledError) as exc: self.conn_menu.port_set_spin.setEnabled(False)
await self.thermostat.end_session()
if isinstance(exc, asyncio.CancelledError): try:
await self.thermostat.start_session(host=host, port=port)
except StoppedConnecting:
return return
raise await self._on_connection_changed(True)
finally: else:
self._connecting_task = None await self.bail()
elif self._connecting_task is not None: # TODO: Remove asyncio.TimeoutError in Python 3.11
self._connecting_task.cancel() except (OSError, asyncio.TimeoutError):
else: try:
await self.thermostat.end_session() await self.bail()
except ConnectionResetError:
pass
@asyncSlot(int, PIDAutotuneState) @asyncSlot()
async def pid_autotune_handler(self, _ch, _state): 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 = [] ch_tuning = []
for ch in range(self.NUM_CHANNELS): for ch in range(self.NUM_CHANNELS):
match self.autotuners.get_state(ch): match self.autotuners.get_state(ch):
case PIDAutotuneState.STATE_OFF: case PIDAutotuneState.STATE_OFF:
self.ctrl_panel_view.change_params_title( self.ctrl_panel_view.change_params_title(
ch, ("PID Config", "PID Auto Tune", "Run"), "Run" ch, ("pid", "pid_autotune", "run_pid"), "Run"
) )
case ( case (
PIDAutotuneState.STATE_READY PIDAutotuneState.STATE_READY
@ -230,21 +337,21 @@ class MainWindow(QtWidgets.QMainWindow):
| PIDAutotuneState.STATE_RELAY_STEP_DOWN | PIDAutotuneState.STATE_RELAY_STEP_DOWN
): ):
self.ctrl_panel_view.change_params_title( 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) ch_tuning.append(ch)
case PIDAutotuneState.STATE_SUCCEEDED: case PIDAutotuneState.STATE_SUCCEEDED:
self.info_box.display_info_box( self.info_box.display_info_box(
"PID Autotune Success", "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() self.info_box.show()
case PIDAutotuneState.STATE_FAILED: case PIDAutotuneState.STATE_FAILED:
self.info_box.display_info_box( self.info_box.display_info_box(
"PID Autotune Failed", "PID Autotune Failed",
f"Channel {ch} PID Autotune has failed.", f"Channel {ch} PID Autotune on channel has failed.",
) )
self.info_box.show() self.info_box.show()
@ -259,6 +366,74 @@ class MainWindow(QtWidgets.QMainWindow):
self.loading_spinner.start() self.loading_spinner.start()
self.loading_spinner.show() 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(): async def coro_main():
args = get_argparser().parse_args() 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 heapless::{consts::U2, Vec};
use num_traits::Zero;
use serde::{Serialize, Serializer}; use serde::{Serialize, Serializer};
use smoltcp::time::Instant; use smoltcp::time::Instant;
use stm32f4xx_hal::hal; use stm32f4xx_hal::hal;
@ -32,12 +33,24 @@ pub enum PinsAdcReadTarget {
pub const CHANNELS: usize = 2; pub const CHANNELS: usize = 2;
pub const R_SENSE: f64 = 0.05; pub const R_SENSE: f64 = 0.05;
// as stated in the MAX1968 datasheet // From design specs
pub const MAX_TEC_I: f64 = 3.0; 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 // 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 // TODO: -pub
pub struct Channels { pub struct Channels {
channel0: Channel<Channel0>, channel0: Channel<Channel0>,
@ -128,7 +141,7 @@ impl Channels {
/// i_set DAC /// i_set DAC
fn set_dac(&mut self, channel: usize, voltage: ElectricPotential) -> ElectricPotential { 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 { match channel {
0 => self.channel0.dac.set(value).unwrap(), 0 => self.channel0.dac.set(value).unwrap(),
1 => self.channel1.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 { pub fn set_i(&mut self, channel: usize, i_set: ElectricCurrent) -> ElectricCurrent {
// Silently clamp i_set let i_set = i_set.min(MAX_TEC_I).max(-MAX_TEC_I);
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 vref_meas = match channel.into() { let vref_meas = match channel.into() {
0 => self.channel0.vref_meas, 0 => self.channel0.vref_meas,
1 => self.channel1.vref_meas, 1 => self.channel1.vref_meas,
@ -318,7 +327,7 @@ impl Channels {
best_error = error; best_error = error;
start_value = prev_value; 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 { match channel {
0 => self.channel0.vref_meas = vref, 0 => self.channel0.vref_meas = vref,
1 => self.channel1.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 max = 4.0 * ElectricPotential::new::<volt>(3.3);
let duty = self.get_pwm(channel, PwmPin::MaxV); 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) { pub fn get_max_i_pos(&mut self, channel: usize) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0); let max = ElectricCurrent::new::<ampere>(3.0);
let duty = self.get_pwm(channel, PwmPin::MaxIPos); 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) { pub fn get_max_i_neg(&mut self, channel: usize) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0); let max = ElectricCurrent::new::<ampere>(3.0);
let duty = self.get_pwm(channel, PwmPin::MaxINeg); let duty = self.get_pwm(channel, PwmPin::MaxINeg);
(duty * max, max) (duty * max, MAX_TEC_I)
} }
// Get current passing through TEC // 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) { pub fn set_max_v(&mut self, channel: usize, max_v: ElectricPotential) -> (ElectricPotential, ElectricPotential) {
let max = 4.0 * ElectricPotential::new::<volt>(3.3); 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); let duty = self.set_pwm(channel, PwmPin::MaxV, duty);
(duty * max, max) (duty * max, max)
} }
pub fn set_max_i_pos(&mut self, channel: usize, max_i_pos: ElectricCurrent) -> (ElectricCurrent, ElectricCurrent) { pub fn set_max_i_pos(&mut self, channel: usize, max_i_pos: ElectricCurrent) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0); 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); let duty = self.set_pwm(channel, PwmPin::MaxIPos, duty);
(duty * max, max) (duty * max, max)
} }
pub fn set_max_i_neg(&mut self, channel: usize, max_i_neg: ElectricCurrent) -> (ElectricCurrent, ElectricCurrent) { pub fn set_max_i_neg(&mut self, channel: usize, max_i_neg: ElectricCurrent) -> (ElectricCurrent, ElectricCurrent) {
let max = ElectricCurrent::new::<ampere>(3.0); 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); let duty = self.set_pwm(channel, PwmPin::MaxINeg, duty);
(duty * max, max) (duty * max, max)
} }
@ -509,8 +518,8 @@ impl Channels {
PwmSummary { PwmSummary {
channel, channel,
center: CenterPointJson(self.channel_state(channel).center.clone()), center: CenterPointJson(self.channel_state(channel).center.clone()),
i_set: (self.get_i(channel), ElectricCurrent::new::<ampere>(3.0)).into(), i_set: (self.get_i(channel), MAX_TEC_I).into(),
max_v: (self.get_max_v(channel), ElectricPotential::new::<volt>(5.0)).into(), max_v: self.get_max_v(channel).into(),
max_i_pos: self.get_max_i_pos(channel).into(), max_i_pos: self.get_max_i_pos(channel).into(),
max_i_neg: self.get_max_i_neg(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 serde::{Serialize, Deserialize};
use uom::si::{ use uom::si::{
electric_potential::volt, electric_potential::volt,
@ -18,6 +19,7 @@ pub struct ChannelConfig {
pid: pid::Parameters, pid: pid::Parameters,
pid_target: f32, pid_target: f32,
pid_engaged: bool, pid_engaged: bool,
i_set: ElectricCurrent,
sh: steinhart_hart::Parameters, sh: steinhart_hart::Parameters,
pwm: PwmLimits, pwm: PwmLimits,
/// uses variant `PostFilter::Invalid` instead of `None` to save space /// uses variant `PostFilter::Invalid` instead of `None` to save space
@ -33,11 +35,17 @@ impl ChannelConfig {
.unwrap_or(PostFilter::Invalid); .unwrap_or(PostFilter::Invalid);
let state = channels.channel_state(channel); let state = channels.channel_state(channel);
let i_set = if state.pid_engaged {
ElectricCurrent::zero()
} else {
state.i_set
};
ChannelConfig { ChannelConfig {
center: state.center.clone(), center: state.center.clone(),
pid: state.pid.parameters.clone(), pid: state.pid.parameters.clone(),
pid_target: state.pid.target as f32, pid_target: state.pid.target as f32,
pid_engaged: state.pid_engaged, pid_engaged: state.pid_engaged,
i_set: i_set,
sh: state.sh.clone(), sh: state.sh.clone(),
pwm, pwm,
adc_postfilter, adc_postfilter,
@ -59,6 +67,7 @@ impl ChannelConfig {
adc_postfilter => Some(adc_postfilter), adc_postfilter => Some(adc_postfilter),
}; };
let _ = channels.adc.set_postfilter(channel as u8, 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 { impl PwmLimits {
pub fn new(channels: &mut Channels, channel: usize) -> Self { 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_pos, _) = channels.get_max_i_pos(channel);
let (max_i_neg, _) = channels.get_max_i_neg(channel); let (max_i_neg, _) = channels.get_max_i_neg(channel);
PwmLimits { PwmLimits {

View File

@ -54,7 +54,7 @@ impl FanCtrl {
pub fn cycle(&mut self, abs_max_tec_i: ElectricCurrent) { pub fn cycle(&mut self, abs_max_tec_i: ElectricCurrent) {
self.abs_max_tec_i = abs_max_tec_i.get::<ampere>() as f32; self.abs_max_tec_i = abs_max_tec_i.get::<ampere>() as f32;
if self.fan_auto && self.hw_settings.fan_available { 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() // 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; 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); self.set_pwm(pwm);

View File

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