Files
brewpi/tasks/sud.py
T
jens 929b8758c4 Remove the ad-hoc cooling override now that COOL handles it natively
TempControllerBase.cooling/set_cooling() forced y=0 in advance of a
Sud ramp-down step; redundant now that the FSM's own COOL state
detects and handles a negative diff itself. Removed from the
controller, SudTask's anticipatory detection (and the Step message's
now-unused "Cooling" field), and demo_sud.py's mirror of it.

gui: the "Cool down" status-bar indicator now reflects the
controller's actual State ("States.COOL") via the TempCtrl channel,
rather than Sud's removed anticipatory flag - renamed
sud_cooling/set_sud_cooling to tc_cooling/set_tc_cooling to match.
2026-06-21 13:48:23 +02:00

141 lines
5.5 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.plant import APlant
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, pot: APlant, dt, interval, msg_handler: MsgIo):
ATask.__init__(self, interval)
self.sud = sud
self.tc = tc
self.stirrer = stirrer
self.pot = pot
# Simulated seconds per tick, vs. interval's wall-clock seconds per
# tick - same dt/interval split Pot/TempController/Stirrer already
# use internally. Their warp-induced speedup falls out naturally
# from ticking real physics at simulated dt; Sud has no physics of
# its own, so hold_remaining must be ticked by dt explicitly to get
# the same speedup instead of running in real time.
self.dt = dt
self.msg_handler = msg_handler
msg_handler.set_recv_handler(self.recv)
def apply_plant_params(self, step):
"""Keeps the real plant's and the controller's internal model's
lumped (M, C) in sync with the step's grain_mass/water_mass, since
those vary over the course of a brew (malt going in, water boiling
off) - mirrors demo_sud.py's apply_plant_params()."""
params = self.sud.derive_plant_params(step.get('grain_mass', 0), step.get('water_mass', 0))
self.pot.set_thermal_params(params['M'], params['C'])
if hasattr(self.tc, 'set_model_params'):
self.tc.set_model_params(params['M'], params['C'])
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)
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
# 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 ramping and ramp is not None:
self.tc.set_theta_soll(step['temperature'])
self.tc.set_heatrate_soll(ramp['rate'])
self.apply_stirrer(phase)
asyncio.create_task(self.send({'Step': {
'Index': self.sud.index,
'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': step.get('temperature') if ramp is not None else None,
'Rate': ramp.get('rate') if ramp 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,
}}))
def on_state_changed(self, value):
asyncio.create_task(self.send({'State': str(value)}))
if value in (SudState.DONE, SudState.IDLE):
self.stirrer.set_duty_cycle(1.0)
self.stirrer.set_speed(0)
# A finished/stopped run no longer owns the controller - hand
# control back to manual mode (off by default there too).
self.tc.set_enabled(False)
else:
# Any other state (RAMPING/HOLDING/WAIT_USER/PAUSED) means a
# run is in progress and needs the controller actively driving
# the heater.
self.tc.set_enabled(True)
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()
elif 'Pause' in pair[0]:
self.sud.pause()
elif 'Stop' in pair[0]:
self.sud.stop()
elif 'Save' in pair[0]:
await self.send({'Json': self.sud.save()})
elif 'Load' in pair[0]:
if self.sud.load(pair[1]):
await self.send({'Name': self.sud.name, 'Description': self.sud.description})
await self.send({'Json': pair[1]})
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.dt)
await asyncio.sleep(self.interval)