Add mash-schedule (Sud) automation: temp/rate sequencing + stirrer switching

brewpi.py had a dead -m/--model CLI arg and sude/*.json schedules were
pure data with no code to drive them, despite the README documenting
them as if they were already wired up.

Add components/sud.py's Sud, which loads a sude/*.json schedule and
steps through its Rasten (ramp -> hold -> optional wait-for-user ->
next rest), and tasks/sud.py's SudTask, which drives the temperature
controller's theta_soll/heatrate_soll from the current rest and
switches the stirrer between continuous (ramping) and intermittent
(holding, via stirrSpeedRast/stirrDutyRast/stirrCycleTime) operation.
Exposes progress on a new "Sud" WebSocket channel and accepts
Start/Confirm commands. Enabled via a new optional top-level "sud"
config key (see config.json.templ/.sim).

"Reached target" is detected via abs(theta_ist - theta_soll_set) <
tolerance rather than tc.state == HOLD, since the latter can still
read HOLD left over from the previous rest for one tick after a new
target is pushed (tc.state only updates on the controller's own,
independently-scheduled process() tick).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 17:54:42 +02:00
co-authored by Claude Sonnet 4.6
parent 1e89a58370
commit 7e131df4ca
8 changed files with 188 additions and 6 deletions
+25 -5
View File
@@ -38,6 +38,9 @@ components/ Pluggable building blocks behind factories:
actor/ heater and stirrer drivers: simulated, a
Hendi induction hob (serial protocol), or a
Pololu 1376 stirrer motor controller
sud.py optional mash-schedule sequencer that steps
through a sude/*.json schedule and drives
the temperature controller from it
tasks/ Async tasks (one per component) that poll
hardware/sim state at a fixed interval and
@@ -46,7 +49,7 @@ tasks/ Async tasks (one per component) that poll
ws/ Minimal WebSocket pub/sub layer: server
(single- and multi-user), client, and a
keyed message dispatcher (e.g. "Sensor",
"Heater", "TempCtrl", "Stirrer", "Pot").
"Heater", "TempCtrl", "Stirrer", "Pot", "Sud").
tracer.py Logs traced variables to .mat files (for
offline analysis/tuning in MATLAB/Octave,
@@ -118,10 +121,27 @@ environment (`$WORKON_HOME`/`$BREWPI_HOME`) on a Raspberry Pi-style deployment.
## Mash schedules
Files under `sude/` describe a brew's mash schedule: pot weight, malt/water
weights, stirrer speed/duty, and a list of temperature rests, each with a
target temperature, heating rate, and whether to pause for user confirmation
before continuing (e.g. to add malt or check gravity).
Files under `sude/` describe a brew's mash schedule ("Sud"): pot weight,
malt/water weights, stirrer speed/duty, and a list of temperature rests
("Rasten"), each with a target `temp`, `heatRate`, hold `time` (minutes), and
whether to `waitForUser` before continuing (e.g. to add malt or check
gravity).
Set the top-level `sud` config key to a path under `sude/` (see
`config.json.sim`/`config.json.templ`) to have the server step through it
automatically: `components/sud.py`'s `Sud` tracks the current rest and
`tasks/sud.py`'s `SudTask` drives the temperature controller's
`theta_soll`/`heatrate_soll` from it, advancing once `theta_ist` settles
close to the target and the rest's hold time has elapsed. It also switches
the stirrer between the schedule's `stirrSpeedHeat` (continuous, while
ramping) and `stirrSpeedRast`/`stirrDutyRast`/`stirrCycleTime` (intermittent,
while holding), stopping it entirely during a `waitForUser` pause. It
exposes progress (current rest, remaining hold time, state) on the `"Sud"`
WebSocket channel and accepts `{"Sud": {"Start": true}}` to begin the
schedule and `{"Sud": {"Confirm": true}}` to acknowledge a `waitForUser`
pause and move to the next rest. Omit `sud` from the config to
run without schedule automation (manual `theta_soll`/`heatrate_soll` control
via the GUI, as before).
## Logging & analysis
+8 -1
View File
@@ -8,7 +8,8 @@ from components.sensor import TempSensorFactory
from components.pid import PidFactory
from components.plant import Pot
from components.actor import HeaterFactory, StirrerFactory
from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, StirrerTask, TracerTask
from components.sud import Sud
from tasks import TaskManager, TempSensorTask, HeaterTask, PotTask, TcTask, StirrerTask, TracerTask, SudTask
from tracer import Tracer
import argparse as ap
@@ -61,6 +62,12 @@ if __name__ == '__main__':
stirrer_task = StirrerTask(stirrer, DT_TASK, dispatcher.msgio_get("Stirrer"))
taskmgr.add(stirrer_task)
# Mash schedule (optional)
sud_path = config.get('sud')
if sud_path:
sud = Sud(sud_path)
taskmgr.add(SudTask(sud, tc, stirrer, DT_TASK, dispatcher.msgio_get("Sud")))
# Tracer
taskmgr.add(TracerTask(sensor, heater, tc, trace_tc, DT_TASK_TRACER, dispatcher.msgio_get("Tracer")))
+1
View File
@@ -2,3 +2,4 @@ from components.atemperatureSensor import *
from components.astirrer import *
from components.aheater import *
from components.apid import *
from components.sud import *
+70
View File
@@ -0,0 +1,70 @@
import json
from enum import Enum
from utils.value import AttributeChange
class SudState(Enum):
IDLE = 0
RAMPING = 1
HOLDING = 2
WAIT_USER = 3
DONE = 4
class Sud(AttributeChange):
def __init__(self, path):
AttributeChange.__init__(self)
with open(path) as f:
data = json.load(f)
self.name = data.get('Name', '')
self.description = data.get('Description', '')
self.rasten = data['Rasten']
self.stirr_speed_heat = data.get('stirrSpeedHeat', 0)
self.stirr_speed_rast = data.get('stirrSpeedRast', 0)
self.stirr_duty_rast = data.get('stirrDutyRast', 1.0)
self.stirr_cycle_time = data.get('stirrCycleTime', 1.0)
self.index = -1
self.hold_remaining = 0.0
self.state = SudState.IDLE
self.rast = None
def start(self):
if self.state != SudState.IDLE:
return
self.index = -1
self._advance()
def confirm(self):
if self.state == SudState.WAIT_USER:
self._advance()
def temp_reached(self):
if self.state == SudState.RAMPING:
self.state = SudState.HOLDING
def tick(self, dt):
if self.state == SudState.HOLDING:
self.hold_remaining -= dt
if self.hold_remaining <= 0:
self._finish_rast()
def _advance(self):
self.index += 1
if self.index >= len(self.rasten):
self.state = SudState.DONE
self.rast = None
return
next_rast = self.rasten[self.index]
self.hold_remaining = next_rast['time'] * 60.0
self.state = SudState.RAMPING
self.rast = next_rast
def _finish_rast(self):
if self.rast.get('waitForUser', False):
self.state = SudState.WAIT_USER
else:
self._advance()
+1
View File
@@ -1,5 +1,6 @@
{
"ambient_temperature": 20,
"sud": "sude/sud_0010.json",
"Controller" : {
"dt": 0.1,
"sim_warp_factor": 10.0,
+1
View File
@@ -1,5 +1,6 @@
{
"ambient_temperature": 20,
"sud": "sude/sud_0010.json",
"Controller" : {
"dt": 1,
"sim_warp_factor": 10.0,
+1
View File
@@ -5,3 +5,4 @@ from tasks.stirrer import *
from tasks.pot import *
from tasks.tempctrl import *
from tasks.tracer import *
from tasks.sud import *
+81
View File
@@ -0,0 +1,81 @@
import asyncio
from tasks import ATask
from ws.message import MsgIo
from utils.value import ChangedFloat
from components import APid, AStirrer
from components.sud import Sud, SudState
# How close theta_ist needs to be to theta_soll_set to count as "reached".
TEMP_REACHED_TOLERANCE = 0.2
class SudTask(ATask):
def __init__(self, sud: Sud, tc: APid, stirrer: AStirrer, interval, msg_handler: MsgIo):
ATask.__init__(self, interval)
self.sud = sud
self.tc = tc
self.stirrer = stirrer
self.msg_handler = msg_handler
msg_handler.set_recv_handler(self.recv)
def on_rast_changed(self, rast):
if rast is not None:
self.tc.set_theta_soll(rast['temp'])
self.tc.set_heatrate_soll(rast['heatRate'])
asyncio.create_task(self.send({'Rast': {
'Index': self.sud.index,
'Temp': rast['temp'] if rast else None,
'HeatRate': rast['heatRate'] if rast else None,
'Time': rast['time'] if rast else None,
'WaitForUser': rast.get('waitForUser', False) if rast else None,
}}))
def on_state_changed(self, value):
asyncio.create_task(self.send({'State': str(value)}))
if value == SudState.RAMPING:
# Stir continuously while ramping to a new target temperature.
self.stirrer.set_duty_cycle(1.0)
self.stirrer.set_speed(self.sud.stirr_speed_heat)
elif value == SudState.HOLDING:
# Stir intermittently while holding at the rest's temperature.
self.stirrer.set_cycle_time(self.sud.stirr_cycle_time)
self.stirrer.set_duty_cycle(self.sud.stirr_duty_rast)
self.stirrer.set_speed(self.sud.stirr_speed_rast)
elif value in (SudState.WAIT_USER, SudState.DONE):
# Stop stirring while paused for the user or once the schedule is done.
self.stirrer.set_duty_cycle(1.0)
self.stirrer.set_speed(0)
def on_hold_remaining_changed(self, value):
asyncio.create_task(self.send({'HoldRemaining': value}))
async def recv(self, data):
for pair in data.items():
if 'Start' in pair[0]:
self.sud.start()
elif 'Confirm' in pair[0]:
self.sud.confirm()
async def send(self, data):
await self.msg_handler.send(data)
async def on_process(self):
print("{}: Started with interval {} s".format(self.msg_handler.get_key(), self.interval))
self.sud.set_on_changed('rast', self.on_rast_changed)
self.sud.set_on_changed('state', self.on_state_changed)
self.sud.set_on_changed('hold_remaining', ChangedFloat(self.on_hold_remaining_changed, prec=0).set)
asyncio.create_task(self.send({'Name': self.sud.name, 'Description': self.sud.description}))
while True:
if self.sud.state == SudState.RAMPING:
# Compare against the controller's own live setpoint/measurement
# rather than its state machine, which can still read HOLD from
# the previous rest for one tick after a new target is pushed.
if abs(self.tc.get_theta_ist() - self.tc.get_theta_soll_set()) < TEMP_REACHED_TOLERANCE:
self.sud.temp_reached()
self.sud.tick(self.interval)
await asyncio.sleep(self.interval)