From 9bd9889cb029ce188bd8c24d8e2bd75b6bcbcf41 Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Sun, 21 Jun 2026 09:01:04 +0200 Subject: [PATCH] Allow a Sud step to combine a ramp and a hold A step can now carry both 'ramp' and 'hold': it ramps to the target temp, then holds there for the given duration, instead of needing two separate schedule entries. Sud.temp_reached() switches such a step from its ramp phase into its hold phase in place (re-firing the step-changed callback so SudTask/demo_sud re-apply the hold's stirrer settings); user_wait_for_continue still applies once the whole step - both phases - is done. Merges sud_0010.json's three ramp/hold pairs into combined steps. --- README.md | 10 ++++++++-- client/brewpi_gui.py | 5 +++-- components/sud.py | 13 ++++++++++++- scripts/demos/sud/demo_sud.py | 12 ++++++++---- sude/sud_0010.json | 34 ++++++++++++---------------------- tasks/sud.py | 20 ++++++++++++++------ 6 files changed, 57 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index f5e7aa1..f2aab1f 100644 --- a/README.md +++ b/README.md @@ -129,13 +129,19 @@ environment (`$WORKON_HOME`/`$BREWPI_HOME`) on a Raspberry Pi-style deployment. ## Mash schedules Files under `sude/` describe a brew's mash schedule ("Sud"): `pot_mass`, -`pot_material` (fixed for the whole brew), and a `steps` list, each either: +`pot_material` (fixed for the whole brew), and a `steps` list, each a ramp, a +hold, or both: - a ramp — `"ramp": {"rate": ..., "temp": ...}` — ramp to `temp` at `rate` - (°C/min), or + (°C/min), and/or - a hold — `"hold": {"duration": ...}` — hold the current target for `duration` minutes (omit/`0` for an immediate step). +A step with both ramps to `temp` and then holds there for `duration` - +useful for a mash rest ("ramp to 63°C, then hold 40 min") without needing two +separate schedule entries. `user_wait_for_continue`/`user_message` (below) +apply once the whole step is done, i.e. after the hold phase if there is one. + Both `ramp` and `hold` carry a `stirrer` block (`speed`, `interval_time`, `on_ratio`): `interval_time: 0` runs the stirrer continuously at `speed`; `interval_time > 0` pulses it on a period of `interval_time` seconds, on for diff --git a/client/brewpi_gui.py b/client/brewpi_gui.py index 3c68981..9491f5c 100755 --- a/client/brewpi_gui.py +++ b/client/brewpi_gui.py @@ -130,7 +130,8 @@ class SudForecastPlot(FigureCanvasQTAgg): def _estimate_course(schedule, start_theta): """Walks the schedule, accumulating (t, theta) waypoints: ramps take abs(delta)/rate minutes at the declared rate, holds last for their - duration at the temperature the preceding ramp reached.""" + duration at the temperature the preceding ramp reached. A step may + carry both - ramp then hold - contributing both segments in turn.""" t = [0.0] theta = [start_theta] for step in schedule: @@ -142,7 +143,7 @@ class SudForecastPlot(FigureCanvasQTAgg): if rate > 0: t.append(t[-1] + abs(target - theta[-1]) / rate * 60.0) theta.append(target) - elif hold is not None: + if hold is not None: t.append(t[-1] + hold.get('duration', 0) * 60.0) theta.append(theta[-1]) return t, theta diff --git a/components/sud.py b/components/sud.py index 87d81ac..f5fa6de 100644 --- a/components/sud.py +++ b/components/sud.py @@ -157,7 +157,18 @@ class Sud(AttributeChange): self._advance() def temp_reached(self): - if self.state == SudState.RAMPING: + if self.state != SudState.RAMPING: + return + if 'hold' in self.step: + # Same step, ramp phase done - move into its hold phase rather + # than finishing the step. Re-assign self.step (AttributeChange + # fires on every assignment, not just on change) to re-trigger + # the step-changed callback so e.g. the hold's stirrer settings + # get (re-)applied. + self.hold_remaining = self.step['hold'].get('duration', 0) * 60.0 + self.state = SudState.HOLDING + self.step = self.step + else: self._finish_step() def tick(self, dt): diff --git a/scripts/demos/sud/demo_sud.py b/scripts/demos/sud/demo_sud.py index 12d2098..639aa5d 100644 --- a/scripts/demos/sud/demo_sud.py +++ b/scripts/demos/sud/demo_sud.py @@ -53,8 +53,8 @@ if __name__ == '__main__': heater.set_on_changed('power_set', ctrl.set_model_power) heater.set_on_changed('power_eff', plant.set_power) - def apply_stirrer(step): - stirrer_cfg = step.get('ramp', step.get('hold', {})).get('stirrer', {}) + def apply_stirrer(phase): + stirrer_cfg = phase.get('stirrer', {}) speed = stirrer_cfg.get('speed', 0) interval_time = stirrer_cfg.get('interval_time', 0) on_ratio = stirrer_cfg.get('on_ratio', 1.0) @@ -72,13 +72,17 @@ if __name__ == '__main__': ctrl.set_model_params(params['M'], params['C']) def on_step_changed(step): + # A step may carry both 'ramp' and 'hold' (ramp to temp, then hold); + # sud.state tells us which phase is currently active. if step is not None: + ramping = sud.state == SudState.RAMPING + phase = step['ramp'] if ramping and 'ramp' in step else step.get('hold', {}) print(f"Step {step['number']}: {step['descr']}") apply_plant_params(step) - if 'ramp' in step: + if ramping and 'ramp' in step: ctrl.set_theta_soll(step['ramp']['temp']) ctrl.set_heatrate_soll(step['ramp']['rate']) - apply_stirrer(step) + apply_stirrer(phase) def on_state_changed(state): if state == SudState.WAIT_USER and sud.user_message: diff --git a/sude/sud_0010.json b/sude/sud_0010.json index 345569a..9c66d71 100644 --- a/sude/sud_0010.json +++ b/sude/sud_0010.json @@ -54,20 +54,17 @@ { "descr": "Einmaischen Rast", "hold": { - "duration": 2000 + "duration": 20 } }, { - "descr": "Heizen Glucose-Rast", + "descr": "Glucose-Rast", "ramp": { "rate": 1.0, "temp": 63 - } - }, - { - "descr": "Halten Glucose-Rast", + }, "hold": { - "duration": 4000, + "duration": 40, "stirrer": { "speed": 20, "interval_time": 90, @@ -76,16 +73,13 @@ } }, { - "descr": "Heizen Verzuckerungs-Rast", + "descr": "Verzuckerungs-Rast", "ramp": { "rate": 1.0, "temp": 72 - } - }, - { - "descr": "Halten Verzuckerungs-Rast", + }, "hold": { - "duration": 3000, + "duration": 30, "stirrer": { "speed": 35, "interval_time": 120 @@ -94,19 +88,15 @@ }, { "water_mass": 20, - "descr": "Heizen Abmaischen", + "descr": "Abmaischen", + "user_message": "Quittieren zum Abmaischen", + "user_wait_for_continue": true, "ramp": { "rate": 1.0, "temp": 76 - } - }, - { - "water_mass": 20, - "descr": "Halten Abmaischen", - "user_message": "Quittieren zum Abmaischen", - "user_wait_for_continue": true, + }, "hold": { - "duration": 300, + "duration": 30, "stirrer": { "speed": 50 } diff --git a/tasks/sud.py b/tasks/sud.py index 00050ac..770b918 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -37,8 +37,8 @@ class SudTask(ATask): if hasattr(self.tc, 'set_model_params'): self.tc.set_model_params(params['M'], params['C']) - def apply_stirrer(self, step): - stirrer_cfg = step.get('ramp', step.get('hold', {})).get('stirrer', {}) + def apply_stirrer(self, phase): + stirrer_cfg = phase.get('stirrer', {}) speed = stirrer_cfg.get('speed', 0) interval_time = stirrer_cfg.get('interval_time', 0) on_ratio = stirrer_cfg.get('on_ratio', 1.0) @@ -53,20 +53,28 @@ class SudTask(ATask): def on_step_changed(self, step): ramp = step.get('ramp') if step else None hold = step.get('hold') if step else None + # A step may carry both 'ramp' and 'hold' (ramp to temp, then hold) - + # self.sud.state tells us which phase is currently active; it's + # already up to date by the time this callback fires, both on a + # full step transition and on the ramp->hold phase switch within + # one step (components/sud.py's temp_reached()). + ramping = self.sud.state == SudState.RAMPING + phase = ramp if ramping and ramp is not None else hold + if step is not None: self.apply_plant_params(step) - if ramp is not None: + if ramping and ramp is not None: self.tc.set_theta_soll(ramp['temp']) self.tc.set_heatrate_soll(ramp['rate']) - self.apply_stirrer(step) + self.apply_stirrer(phase) asyncio.create_task(self.send({'Step': { 'Index': self.sud.index, - 'Type': 'ramp' if ramp is not None else 'hold' if hold is not None else None, + 'Type': 'ramp' if ramping and ramp is not None else 'hold' if hold is not None else None, 'Descr': step.get('descr') if step else None, 'Temp': ramp.get('temp') if ramp else None, 'Rate': ramp.get('rate') if ramp else None, - 'Duration': hold.get('duration') if hold 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, }}))