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:
@@ -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