Allow a Sud step to combine a ramp and a hold

A step can now carry both 'ramp' and 'hold': it ramps to the target
temp, then holds there for the given duration, instead of needing two
separate schedule entries. Sud.temp_reached() switches such a step
from its ramp phase into its hold phase in place (re-firing the
step-changed callback so SudTask/demo_sud re-apply the hold's stirrer
settings); user_wait_for_continue still applies once the whole step -
both phases - is done.

Merges sud_0010.json's three ramp/hold pairs into combined steps.
This commit is contained in:
2026-06-21 09:01:04 +02:00
parent 56de5cf4d6
commit 9bd9889cb0
6 changed files with 57 additions and 37 deletions
+8 -2
View File
@@ -129,13 +129,19 @@ environment (`$WORKON_HOME`/`$BREWPI_HOME`) on a Raspberry Pi-style deployment.
## Mash schedules
Files under `sude/` describe a brew's mash schedule ("Sud"): `pot_mass`,
`pot_material` (fixed for the whole brew), and a `steps` list, each either:
`pot_material` (fixed for the whole brew), and a `steps` list, each a ramp, a
hold, or both:
- a ramp — `"ramp": {"rate": ..., "temp": ...}` — ramp to `temp` at `rate`
(°C/min), or
(°C/min), and/or
- a hold — `"hold": {"duration": ...}` — hold the current target for
`duration` minutes (omit/`0` for an immediate step).
A step with both ramps to `temp` and then holds there for `duration` -
useful for a mash rest ("ramp to 63°C, then hold 40 min") without needing two
separate schedule entries. `user_wait_for_continue`/`user_message` (below)
apply once the whole step is done, i.e. after the hold phase if there is one.
Both `ramp` and `hold` carry a `stirrer` block (`speed`, `interval_time`,
`on_ratio`): `interval_time: 0` runs the stirrer continuously at `speed`;
`interval_time > 0` pulses it on a period of `interval_time` seconds, on for
+3 -2
View File
@@ -130,7 +130,8 @@ class SudForecastPlot(FigureCanvasQTAgg):
def _estimate_course(schedule, start_theta):
"""Walks the schedule, accumulating (t, theta) waypoints: ramps take
abs(delta)/rate minutes at the declared rate, holds last for their
duration at the temperature the preceding ramp reached."""
duration at the temperature the preceding ramp reached. A step may
carry both - ramp then hold - contributing both segments in turn."""
t = [0.0]
theta = [start_theta]
for step in schedule:
@@ -142,7 +143,7 @@ class SudForecastPlot(FigureCanvasQTAgg):
if rate > 0:
t.append(t[-1] + abs(target - theta[-1]) / rate * 60.0)
theta.append(target)
elif hold is not None:
if hold is not None:
t.append(t[-1] + hold.get('duration', 0) * 60.0)
theta.append(theta[-1])
return t, theta
+12 -1
View File
@@ -157,7 +157,18 @@ class Sud(AttributeChange):
self._advance()
def temp_reached(self):
if self.state == SudState.RAMPING:
if self.state != SudState.RAMPING:
return
if 'hold' in self.step:
# Same step, ramp phase done - move into its hold phase rather
# than finishing the step. Re-assign self.step (AttributeChange
# fires on every assignment, not just on change) to re-trigger
# the step-changed callback so e.g. the hold's stirrer settings
# get (re-)applied.
self.hold_remaining = self.step['hold'].get('duration', 0) * 60.0
self.state = SudState.HOLDING
self.step = self.step
else:
self._finish_step()
def tick(self, dt):
+8 -4
View File
@@ -53,8 +53,8 @@ if __name__ == '__main__':
heater.set_on_changed('power_set', ctrl.set_model_power)
heater.set_on_changed('power_eff', plant.set_power)
def apply_stirrer(step):
stirrer_cfg = step.get('ramp', step.get('hold', {})).get('stirrer', {})
def apply_stirrer(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)
@@ -72,13 +72,17 @@ if __name__ == '__main__':
ctrl.set_model_params(params['M'], params['C'])
def on_step_changed(step):
# A step may carry both 'ramp' and 'hold' (ramp to temp, then hold);
# sud.state tells us which phase is currently active.
if step is not None:
ramping = sud.state == SudState.RAMPING
phase = step['ramp'] if ramping and 'ramp' in step else step.get('hold', {})
print(f"Step {step['number']}: {step['descr']}")
apply_plant_params(step)
if 'ramp' in step:
if ramping and 'ramp' in step:
ctrl.set_theta_soll(step['ramp']['temp'])
ctrl.set_heatrate_soll(step['ramp']['rate'])
apply_stirrer(step)
apply_stirrer(phase)
def on_state_changed(state):
if state == SudState.WAIT_USER and sud.user_message:
+12 -22
View File
@@ -54,20 +54,17 @@
{
"descr": "Einmaischen Rast",
"hold": {
"duration": 2000
"duration": 20
}
},
{
"descr": "Heizen Glucose-Rast",
"descr": "Glucose-Rast",
"ramp": {
"rate": 1.0,
"temp": 63
}
},
{
"descr": "Halten Glucose-Rast",
},
"hold": {
"duration": 4000,
"duration": 40,
"stirrer": {
"speed": 20,
"interval_time": 90,
@@ -76,16 +73,13 @@
}
},
{
"descr": "Heizen Verzuckerungs-Rast",
"descr": "Verzuckerungs-Rast",
"ramp": {
"rate": 1.0,
"temp": 72
}
},
{
"descr": "Halten Verzuckerungs-Rast",
},
"hold": {
"duration": 3000,
"duration": 30,
"stirrer": {
"speed": 35,
"interval_time": 120
@@ -94,19 +88,15 @@
},
{
"water_mass": 20,
"descr": "Heizen Abmaischen",
"descr": "Abmaischen",
"user_message": "Quittieren zum Abmaischen",
"user_wait_for_continue": true,
"ramp": {
"rate": 1.0,
"temp": 76
}
},
{
"water_mass": 20,
"descr": "Halten Abmaischen",
"user_message": "Quittieren zum Abmaischen",
"user_wait_for_continue": true,
},
"hold": {
"duration": 300,
"duration": 30,
"stirrer": {
"speed": 50
}
+14 -6
View File
@@ -37,8 +37,8 @@ class SudTask(ATask):
if hasattr(self.tc, 'set_model_params'):
self.tc.set_model_params(params['M'], params['C'])
def apply_stirrer(self, step):
stirrer_cfg = step.get('ramp', step.get('hold', {})).get('stirrer', {})
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)
@@ -53,20 +53,28 @@ class SudTask(ATask):
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 ramp is not None:
if ramping and ramp is not None:
self.tc.set_theta_soll(ramp['temp'])
self.tc.set_heatrate_soll(ramp['rate'])
self.apply_stirrer(step)
self.apply_stirrer(phase)
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,
'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': ramp.get('temp') if ramp else None,
'Rate': ramp.get('rate') if ramp else None,
'Duration': hold.get('duration') if hold 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,
}}))