Files
brewpi/tasks/sud.py
T
jensandClaude Sonnet 4.6 123318ac85 Redesign Sud schedule format: separate ramp/hold keys with defaults
Steps now declare a "ramp" or "hold" key instead of a flat "type", and
unspecified fields (including nested stirrer settings) fall back to a
new top-level default.step structure. Stirrer config is now
interval_time/on_ratio, mapping directly onto AStirrer's cycle
time/duty cycle instead of separate on/off durations. Update
components/sud.py, tasks/sud.py, the Sud demo, sude/sud_0010.json, and
the README to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgR9tPaSzFkAwRAUyeaaCD
2026-06-20 07:20:52 +02:00

95 lines
3.3 KiB
Python

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 apply_stirrer(self, step):
stirrer_cfg = step.get('ramp', step.get('hold', {})).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)
if interval_time > 0:
self.stirrer.set_cycle_time(interval_time)
self.stirrer.set_duty_cycle(on_ratio)
else:
self.stirrer.set_cycle_time(1.0)
self.stirrer.set_duty_cycle(1.0 if speed > 0 else 0.0)
self.stirrer.set_speed(speed)
def on_step_changed(self, step):
ramp = step.get('ramp') if step else None
hold = step.get('hold') if step else None
if step is not None:
if ramp is not None:
self.tc.set_theta_soll(ramp['temp'])
self.tc.set_heatrate_soll(ramp['rate'])
self.apply_stirrer(step)
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,
'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,
'WaitForUser': step.get('user_wait_for_continue', False) if step else None,
}}))
def on_state_changed(self, value):
asyncio.create_task(self.send({'State': str(value)}))
if value == SudState.DONE:
self.stirrer.set_duty_cycle(1.0)
self.stirrer.set_speed(0)
def on_user_message_changed(self, value):
asyncio.create_task(self.send({'UserMessage': value}))
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('step', self.on_step_changed)
self.sud.set_on_changed('state', self.on_state_changed)
self.sud.set_on_changed('user_message', self.on_user_message_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 step 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)