From 1170718c3bc8ee18bf3acee372fa72e0336bf34d Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Sun, 21 Jun 2026 09:10:13 +0200 Subject: [PATCH] Force the heater off and show a "Cool down" indicator during a passive cooldown A ramp step whose target is below the current actual temperature can only be reached by ambient cooling - the heater can't actively cool. SudTask now detects this once per ramp phase and calls the controller's new set_cooling() override (forces y=0, bypassing the FSM) instead of waiting for its hysteresis-based IDLE transition. The GUI shows a blinking blue "Cool down" label in the status bar while this is active. Progression already only advances once theta_ist matches theta_soll regardless of direction, so no change was needed there. --- client/brewpi_gui.py | 28 ++++++++++++++++++++++++++ components/pid/temp_controller_base.py | 11 +++++++++- scripts/demos/sud/demo_sud.py | 9 +++++++++ tasks/sud.py | 9 +++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index 9491f5c..160897b 100755 --- a/client/brewpi_gui.py +++ b/client/brewpi_gui.py @@ -227,6 +227,18 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.status_label_env = QtWidgets.QLabel() self.statusBar().addPermanentWidget(self.status_label_env) self.update_status_env_label() + + # Blinking indicator for a Sud ramp step that's cooling down + # (target below the current temperature - the heater is forced off + # server-side and we just wait for ambient loss to catch up). + self.sud_cooling = False + self.status_label_cooling = QtWidgets.QLabel("Cool down") + self.status_label_cooling.setStyleSheet("color: blue; font-weight: bold;") + self.status_label_cooling.setVisible(False) + self.statusBar().addPermanentWidget(self.status_label_cooling) + self.cooling_blink_timer = QtCore.QTimer(self) + self.cooling_blink_timer.timeout.connect(self.on_cooling_blink) + self.update_sud_actions() self.btn_connect.clicked.connect(self.on_btn_connect_clicked) self.actionStart.triggered.connect(self.on_action_sud_start) @@ -336,6 +348,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.sud_start_time = None self.sud_paused_total = 0.0 self.sud_pause_started_at = None + self.set_sud_cooling(False) self.update_sud_actions() self.update_status_env_label() @@ -361,6 +374,20 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): ambient = "{:.1f}°C".format(self.ambient_temp) if self.ambient_temp is not None else "-" self.status_label_env.setText("Ambient: {} Warp: {:.1f}x".format(ambient, self.warp_factor)) + def set_sud_cooling(self, cooling): + if cooling == self.sud_cooling: + return + self.sud_cooling = cooling + if cooling: + self.status_label_cooling.setVisible(True) + self.cooling_blink_timer.start(500) + else: + self.cooling_blink_timer.stop() + self.status_label_cooling.setVisible(False) + + def on_cooling_blink(self): + self.status_label_cooling.setVisible(not self.status_label_cooling.isVisible()) + def on_slider_temp_soll_changed(self, value): print("on_slider_temp_soll_changed {}".format(value)) self.msg_tempctrl.send({'Soll': {'Temp': value}}) @@ -544,6 +571,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow): self.statusBar().showMessage("Step {}: {}".format(step.get('Index'), descr)) else: self.statusBar().clearMessage() + self.set_sud_cooling(step.get('Cooling', False)) self.update_sud_actions() def update_sud_forecast(self, doc): diff --git a/components/pid/temp_controller_base.py b/components/pid/temp_controller_base.py index 7770708..d3393ca 100644 --- a/components/pid/temp_controller_base.py +++ b/components/pid/temp_controller_base.py @@ -37,6 +37,12 @@ class TempControllerBase(APid): self.pid_hold.set_params(params['Hold']) self.pid_rate.set_params(params['Heat']) self.is_startup = True + # Explicit override for a ramp step whose target is below the + # current temperature: the heater can't actively cool, so rather + # than wait for the FSM's own (slower, hysteresis-based) IDLE + # transition, force y to 0 as soon as the caller (SudTask) knows + # the step needs to passively cool down. + self.cooling = False def on_state_entered(self, state): pass @@ -70,6 +76,9 @@ class TempControllerBase(APid): def set_heatrate_soll(self, value): self.heatrate_soll_set = value + def set_cooling(self, value): + self.cooling = value + def get_heatrate_soll(self): return self.heatrate_soll @@ -109,7 +118,7 @@ class TempControllerBase(APid): self.pid_hold.process(theta_err, -self.theta_ist, hold_scale) self.pid_rate.process(heatrate_err, -self.heatrate_ist) - if self.state == States.IDLE: + if self.state == States.IDLE or self.cooling: self.y = 0 else: self.y = self.pid_rate.get_y() diff --git a/scripts/demos/sud/demo_sud.py b/scripts/demos/sud/demo_sud.py index 639aa5d..7be4d66 100644 --- a/scripts/demos/sud/demo_sud.py +++ b/scripts/demos/sud/demo_sud.py @@ -77,6 +77,15 @@ if __name__ == '__main__': if step is not None: ramping = sud.state == SudState.RAMPING phase = step['ramp'] if ramping and 'ramp' in step else step.get('hold', {}) + + # A ramp step whose target is below the current temperature can + # only be reached by passive cooling - force the heater off, + # mirroring tasks/sud.py's SudTask. + cooling = ramping and 'ramp' in step and step['ramp']['temp'] < ctrl.get_theta_ist() - TEMP_REACHED_TOLERANCE + ctrl.set_cooling(cooling) + if cooling: + print(" -> cooling down to {}".format(step['ramp']['temp'])) + print(f"Step {step['number']}: {step['descr']}") apply_plant_params(step) if ramping and 'ramp' in step: diff --git a/tasks/sud.py b/tasks/sud.py index 770b918..0f7db45 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -61,6 +61,14 @@ class SudTask(ATask): ramping = self.sud.state == SudState.RAMPING phase = ramp if ramping and ramp is not None else hold + # A ramp step whose target is below the current temperature can + # only be reached by passive cooling - force the heater off for + # its whole duration (decided once, here, rather than re-checked + # every tick) instead of relying on the controller's own FSM to + # notice and idle out. + cooling = ramping and ramp is not None and ramp['temp'] < self.tc.get_theta_ist() - TEMP_REACHED_TOLERANCE + self.tc.set_cooling(cooling) + if step is not None: self.apply_plant_params(step) if ramping and ramp is not None: @@ -76,6 +84,7 @@ class SudTask(ATask): 'Rate': ramp.get('rate') if ramp else None, 'Duration': hold.get('duration') if (hold is not None and not ramping) else None, 'WaitForUser': step.get('user_wait_for_continue', False) if step else None, + 'Cooling': cooling, }})) def on_state_changed(self, value):