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:
@@ -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
@@ -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")))
|
||||
|
||||
|
||||
@@ -2,3 +2,4 @@ from components.atemperatureSensor import *
|
||||
from components.astirrer import *
|
||||
from components.aheater import *
|
||||
from components.apid import *
|
||||
from components.sud import *
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
{
|
||||
"ambient_temperature": 20,
|
||||
"sud": "sude/sud_0010.json",
|
||||
"Controller" : {
|
||||
"dt": 0.1,
|
||||
"sim_warp_factor": 10.0,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"ambient_temperature": 20,
|
||||
"sud": "sude/sud_0010.json",
|
||||
"Controller" : {
|
||||
"dt": 1,
|
||||
"sim_warp_factor": 10.0,
|
||||
|
||||
@@ -5,3 +5,4 @@ from tasks.stirrer import *
|
||||
from tasks.pot import *
|
||||
from tasks.tempctrl import *
|
||||
from tasks.tracer import *
|
||||
from tasks.sud import *
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user