Adapt Sud/SudTask/demo to the new flat heat/hold schedule schema

sude/sud_0010.json moved from a list of combined ramp+hold+wait
"Rasten" with global stirrer constants to a flat "schedule" list of
explicit {"type": "heat"|"hold", ...} steps, each carrying its own
stirrer_speed/stirrer_time_on/stirrer_time_off and an optional
user_wait_for_continue (+ user_message).

- components/sud.py: Sud now tracks the current `step` instead of
  `rast`; "heat" steps enter RAMPING (advanced externally via
  temp_reached()), "hold" steps enter HOLDING and count down
  `duration` minutes (0 if omitted). Either step type can pause in
  WAIT_USER via user_wait_for_continue, surfaced through the new
  user_message attribute.
- tasks/sud.py: SudTask only pushes theta_soll/heatrate_soll on
  "heat" steps, converts each step's stirrer_time_on/off into a
  duty_cycle/cycle_time on every step change (replacing the old
  state-based RAMPING/HOLDING stirrer switching), and broadcasts
  user_message changes. Stirring is now fully schedule-driven instead
  of implicitly stopped on WAIT_USER/DONE (DONE still stops it, since
  there's no step left to read settings from).
- scripts/demos/sud/demo_sud.py: mirrors the same step-driven wiring.
- README's Mash schedules section rewritten for the new schema.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 19:09:43 +02:00
co-authored by Claude Sonnet 4.6
parent 2845051350
commit b33f89a569
5 changed files with 176 additions and 96 deletions
+26 -19
View File
@@ -62,9 +62,10 @@ scripts/demos/ Standalone, eyeballed-plot demos (matplotlib)
`python -m scripts.demos.sud.demo_sud`. `python -m scripts.demos.sud.demo_sud`.
sude/ Mash schedules ("Sud" = brew/wort), each a sude/ Mash schedules ("Sud" = brew/wort), each a
JSON list of temperature rests ("Rasten") JSON list of "heat"/"hold" steps with target
with target temperature, heat rate, and temperature/heat rate or hold duration,
optional pause for user confirmation. per-step stirrer timing, and optional pause
for user confirmation.
config.json.templ Configuration template (real hardware). config.json.templ Configuration template (real hardware).
config.json.sim Configuration template for simulation mode. config.json.sim Configuration template for simulation mode.
@@ -128,26 +129,32 @@ environment (`$WORKON_HOME`/`$BREWPI_HOME`) on a Raspberry Pi-style deployment.
## Mash schedules ## Mash schedules
Files under `sude/` describe a brew's mash schedule ("Sud"): pot weight, Files under `sude/` describe a brew's mash schedule ("Sud"): pot weight,
malt/water weights, stirrer speed/duty, and a list of temperature rests malt/water weights, and a flat `schedule` list of steps, each either:
("Rasten"), each with a target `temp`, `heatRate`, hold `time` (minutes), and
whether to `waitForUser` before continuing (e.g. to add malt or check - `"type": "heat"` — ramp to `temp` at `rate` (°C/min), or
gravity). - `"type": "hold"` — hold the current target for `duration` minutes (omit
for an immediate, zero-duration step).
Every step also carries its own stirrer timing (`stirrer_speed`,
`stirrer_time_on`/`stirrer_time_off` in seconds — `time_off: 0` means
continuous), and may set `user_wait_for_continue: true` (with an optional
`user_message`) to pause for confirmation once the step completes, e.g. to
add malt or check gravity, instead of advancing immediately.
Set the top-level `sud` config key to a path under `sude/` (see 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 `config.json.sim`/`config.json.templ`) to have the server step through it
automatically: `components/sud.py`'s `Sud` tracks the current rest and automatically: `components/sud.py`'s `Sud` tracks the current step and
`tasks/sud.py`'s `SudTask` drives the temperature controller's `tasks/sud.py`'s `SudTask` drives the temperature controller's
`theta_soll`/`heatrate_soll` from it, advancing once `theta_ist` settles `theta_soll`/`heatrate_soll` from `"heat"` steps (advancing once `theta_ist`
close to the target and the rest's hold time has elapsed. It also switches settles close to the target) and counts down `"hold"` steps' `duration`,
the stirrer between the schedule's `stirrSpeedHeat` (continuous, while applying each step's stirrer timing as it goes (converting
ramping) and `stirrSpeedRast`/`stirrDutyRast`/`stirrCycleTime` (intermittent, `stirrer_time_on`/`stirrer_time_off` into a duty cycle/cycle time). It
while holding), stopping it entirely during a `waitForUser` pause. It exposes progress (current step, remaining hold time, state, any
exposes progress (current rest, remaining hold time, state) on the `"Sud"` `user_message`) on the `"Sud"` WebSocket channel and accepts
WebSocket channel and accepts `{"Sud": {"Start": true}}` to begin the `{"Sud": {"Start": true}}` to begin the schedule and `{"Sud": {"Confirm":
schedule and `{"Sud": {"Confirm": true}}` to acknowledge a `waitForUser` true}}` to acknowledge a pause and move to the next step. Omit `sud` from
pause and move to the next rest. Omit `sud` from the config to the config to run without schedule automation (manual
run without schedule automation (manual `theta_soll`/`heatrate_soll` control `theta_soll`/`heatrate_soll` control via the GUI, as before).
via the GUI, as before).
## Logging & analysis ## Logging & analysis
+18 -16
View File
@@ -19,17 +19,13 @@ class Sud(AttributeChange):
self.name = data.get('Name', '') self.name = data.get('Name', '')
self.description = data.get('Description', '') self.description = data.get('Description', '')
self.rasten = data['Rasten'] self.schedule = data['schedule']
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.index = -1
self.hold_remaining = 0.0 self.hold_remaining = 0.0
self.state = SudState.IDLE self.state = SudState.IDLE
self.rast = None self.step = None
self.user_message = None
def start(self): def start(self):
if self.state != SudState.IDLE: if self.state != SudState.IDLE:
@@ -43,28 +39,34 @@ class Sud(AttributeChange):
def temp_reached(self): def temp_reached(self):
if self.state == SudState.RAMPING: if self.state == SudState.RAMPING:
self.state = SudState.HOLDING self._finish_step()
def tick(self, dt): def tick(self, dt):
if self.state == SudState.HOLDING: if self.state == SudState.HOLDING:
self.hold_remaining -= dt self.hold_remaining -= dt
if self.hold_remaining <= 0: if self.hold_remaining <= 0:
self._finish_rast() self._finish_step()
def _advance(self): def _advance(self):
self.index += 1 self.index += 1
if self.index >= len(self.rasten): if self.index >= len(self.schedule):
self.state = SudState.DONE self.state = SudState.DONE
self.rast = None self.user_message = None
self.step = None
return return
next_rast = self.rasten[self.index] next_step = self.schedule[self.index]
self.hold_remaining = next_rast['time'] * 60.0 if next_step['type'] == 'heat':
self.state = SudState.RAMPING self.state = SudState.RAMPING
self.rast = next_rast else:
self.hold_remaining = next_step.get('duration', 0) * 60.0
self.state = SudState.HOLDING
def _finish_rast(self): self.user_message = next_step.get('user_message')
if self.rast.get('waitForUser', False): self.step = next_step
def _finish_step(self):
if self.step.get('user_wait_for_continue', False):
self.state = SudState.WAIT_USER self.state = SudState.WAIT_USER
else: else:
self._advance() self._advance()
+27 -17
View File
@@ -52,24 +52,34 @@ if __name__ == '__main__':
heater.set_on_changed('power_set', ctrl.set_model_power) heater.set_on_changed('power_set', ctrl.set_model_power)
heater.set_on_changed('power_eff', plant.set_power) heater.set_on_changed('power_eff', plant.set_power)
def on_rast_changed(rast): def apply_stirrer(step):
if rast is not None: speed = step.get('stirrer_speed', 0)
ctrl.set_theta_soll(rast['temp']) on = step.get('stirrer_time_on', 0)
ctrl.set_heatrate_soll(rast['heatRate']) off = step.get('stirrer_time_off', 0)
cycle = on + off
if cycle > 0:
duty = on / cycle
else:
cycle = 1.0
duty = 1.0 if speed > 0 else 0.0
stirrer.set_cycle_time(cycle)
stirrer.set_duty_cycle(duty)
stirrer.set_speed(speed)
def on_step_changed(step):
if step is not None:
if step['type'] == 'heat':
ctrl.set_theta_soll(step['temp'])
ctrl.set_heatrate_soll(step['rate'])
apply_stirrer(step)
def on_state_changed(state): def on_state_changed(state):
if state == SudState.RAMPING: if state == SudState.DONE:
stirrer.set_duty_cycle(1.0)
stirrer.set_speed(sud.stirr_speed_heat)
elif state == SudState.HOLDING:
stirrer.set_cycle_time(sud.stirr_cycle_time)
stirrer.set_duty_cycle(sud.stirr_duty_rast)
stirrer.set_speed(sud.stirr_speed_rast)
elif state in (SudState.WAIT_USER, SudState.DONE):
stirrer.set_duty_cycle(1.0) stirrer.set_duty_cycle(1.0)
stirrer.set_speed(0) stirrer.set_speed(0)
sud.set_on_changed('rast', on_rast_changed) sud.set_on_changed('step', on_step_changed)
sud.set_on_changed('state', on_state_changed) sud.set_on_changed('state', on_state_changed)
sud.start() sud.start()
@@ -80,7 +90,7 @@ if __name__ == '__main__':
_heatrate_ist = [] _heatrate_ist = []
_heatrate_soll = [] _heatrate_soll = []
_sud_state = [] _sud_state = []
_rast_index = [] _step_index = []
_stirrer_speed = [] _stirrer_speed = []
_stirrer_duty = [] _stirrer_duty = []
_heater_power_set = [] _heater_power_set = []
@@ -121,7 +131,7 @@ if __name__ == '__main__':
_heatrate_ist.append(ctrl.get_heatrate_ist()) _heatrate_ist.append(ctrl.get_heatrate_ist())
_heatrate_soll.append(ctrl.get_heatrate_soll()) _heatrate_soll.append(ctrl.get_heatrate_soll())
_sud_state.append(sud.state.value) _sud_state.append(sud.state.value)
_rast_index.append(sud.index) _step_index.append(sud.index)
_stirrer_speed.append(stirrer.speed * stirrer.isOn) _stirrer_speed.append(stirrer.speed * stirrer.isOn)
_stirrer_duty.append(stirrer.dutyCycle * 100) _stirrer_duty.append(stirrer.dutyCycle * 100)
_heater_power_set.append(heater.power_set) _heater_power_set.append(heater.power_set)
@@ -157,8 +167,8 @@ if __name__ == '__main__':
legend(["Sud state"]) legend(["Sud state"])
grid(True) grid(True)
subplot(3, 1, 2) subplot(3, 1, 2)
step(_t_min, _rast_index, where='post', color='k') step(_t_min, _step_index, where='post', color='k')
legend(["Rast index"]) legend(["Step index"])
grid(True) grid(True)
subplot(3, 1, 3) subplot(3, 1, 3)
plot(_t_min, _stirrer_speed, '-g', _t_min, _stirrer_duty, '-c', linewidth=1) plot(_t_min, _stirrer_speed, '-g', _t_min, _stirrer_duty, '-c', linewidth=1)
+69 -21
View File
@@ -5,35 +5,83 @@
"pot_material" : "Edelstahl 18/10", "pot_material" : "Edelstahl 18/10",
"Schuettung_kg" : 5.21, "Schuettung_kg" : 5.21,
"Wasser_kg" : 22, "Wasser_kg" : 22,
"stirrSpeedHeat" : 30, "schedule":
"stirrSpeedRast" : 30,
"stirrDutyRast" : 1.0,
"stirrCycleTime" : 120,
"Rasten" :
[ [
{ {
"time" : 0, "type": "heat",
"temp" : 57.0, "descr": "Heizen für Einmaischen",
"heatRate" : 1.00, "rate": 1.8,
"waitForUser" : true "temp": 57,
"stirrer_speed": 50,
"stirrer_time_on": 90,
"stirrer_time_off": 0
}, },
{ {
"time" : 40, "type": "hold",
"temp" : 63.0, "descr": "Einmaischen einfüllen",
"heatRate" : 1.00, "stirrer_speed": 0,
"waitForUser" : false "user_message": "Bitte Malz einfüllen und bestätigen",
"user_wait_for_continue" : true
}, },
{ {
"time" : 30, "type": "hold",
"temp" : 72.0, "descr": "Einmaischen Rast",
"heatRate" : 1.00, "duration" : 30,
"waitForUser" : true "stirrer_speed": 30,
"stirrer_time_on": 30,
"stirrer_time_off": 30
}, },
{ {
"time" : 0, "type": "heat",
"temp" : 76.0, "descr": "Heizen Glucose-Rast",
"heatRate" : 1.00, "rate": 1.0,
"waitForUser" : true "temp": 63,
"stirrer_speed": 50,
"stirrer_time_on": 90,
"stirrer_time_off": 0
},
{
"type": "hold",
"descr": "Halten Glucose-Rast",
"duration" : 40,
"stirrer_speed": 30,
"stirrer_time_on": 30,
"stirrer_time_off": 30
},
{
"type": "heat",
"descr": "Heizen Verzuckerungs-Rast",
"rate": 1.0,
"temp": 72,
"stirrer_speed": 50,
"stirrer_time_on": 90,
"stirrer_time_off": 0
},
{
"type": "hold",
"descr": "Halten Verzuckerungs-Rast",
"duration" : 30,
"stirrer_speed": 30,
"stirrer_time_on": 30,
"stirrer_time_off": 30
},
{
"type": "heat",
"descr": "Heizen Abmaischen",
"rate": 1.0,
"temp": 76,
"stirrer_speed": 50,
"stirrer_time_on": 90,
"stirrer_time_off": 0
},
{
"type": "hold",
"descr": "Halten Abmaischen",
"duration" : 30,
"stirrer_speed": 30,
"stirrer_time_on": 30,
"stirrer_time_off": 30,
"user_wait_for_continue" : true
} }
] ]
} }
+35 -22
View File
@@ -18,36 +18,48 @@ class SudTask(ATask):
self.msg_handler = msg_handler self.msg_handler = msg_handler
msg_handler.set_recv_handler(self.recv) msg_handler.set_recv_handler(self.recv)
def on_rast_changed(self, rast): def apply_stirrer(self, step):
if rast is not None: speed = step.get('stirrer_speed', 0)
self.tc.set_theta_soll(rast['temp']) on = step.get('stirrer_time_on', 0)
self.tc.set_heatrate_soll(rast['heatRate']) off = step.get('stirrer_time_off', 0)
cycle = on + off
if cycle > 0:
duty = on / cycle
else:
cycle = 1.0
duty = 1.0 if speed > 0 else 0.0
asyncio.create_task(self.send({'Rast': { self.stirrer.set_cycle_time(cycle)
self.stirrer.set_duty_cycle(duty)
self.stirrer.set_speed(speed)
def on_step_changed(self, step):
if step is not None:
if step['type'] == 'heat':
self.tc.set_theta_soll(step['temp'])
self.tc.set_heatrate_soll(step['rate'])
self.apply_stirrer(step)
asyncio.create_task(self.send({'Step': {
'Index': self.sud.index, 'Index': self.sud.index,
'Temp': rast['temp'] if rast else None, 'Type': step['type'] if step else None,
'HeatRate': rast['heatRate'] if rast else None, 'Descr': step.get('descr') if step else None,
'Time': rast['time'] if rast else None, 'Temp': step.get('temp') if step else None,
'WaitForUser': rast.get('waitForUser', False) if rast else None, 'Rate': step.get('rate') if step else None,
'Duration': step.get('duration') if step else None,
'WaitForUser': step.get('user_wait_for_continue', False) if step else None,
}})) }}))
def on_state_changed(self, value): def on_state_changed(self, value):
asyncio.create_task(self.send({'State': str(value)})) asyncio.create_task(self.send({'State': str(value)}))
if value == SudState.RAMPING: if value == SudState.DONE:
# 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_duty_cycle(1.0)
self.stirrer.set_speed(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): def on_hold_remaining_changed(self, value):
asyncio.create_task(self.send({'HoldRemaining': value})) asyncio.create_task(self.send({'HoldRemaining': value}))
@@ -64,8 +76,9 @@ class SudTask(ATask):
async def on_process(self): async def on_process(self):
print("{}: Started with interval {} s".format(self.msg_handler.get_key(), self.interval)) 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('step', self.on_step_changed)
self.sud.set_on_changed('state', self.on_state_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) 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})) asyncio.create_task(self.send({'Name': self.sud.name, 'Description': self.sud.description}))
@@ -74,7 +87,7 @@ class SudTask(ATask):
if self.sud.state == SudState.RAMPING: if self.sud.state == SudState.RAMPING:
# Compare against the controller's own live setpoint/measurement # Compare against the controller's own live setpoint/measurement
# rather than its state machine, which can still read HOLD from # rather than its state machine, which can still read HOLD from
# the previous rest for one tick after a new target is pushed. # 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: if abs(self.tc.get_theta_ist() - self.tc.get_theta_soll_set()) < TEMP_REACHED_TOLERANCE:
self.sud.temp_reached() self.sud.temp_reached()
self.sud.tick(self.interval) self.sud.tick(self.interval)