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
This commit is contained in:
2026-06-20 07:20:52 +02:00
co-authored by Claude Sonnet 4.6
parent 2dcf644bed
commit 123318ac85
5 changed files with 172 additions and 107 deletions
+30 -20
View File
@@ -131,33 +131,43 @@ environment (`$WORKON_HOME`/`$BREWPI_HOME`) on a Raspberry Pi-style deployment.
Files under `sude/` describe a brew's mash schedule ("Sud"): `pot_mass`,
`pot_material`, `grain_mass`, `water_mass` (used by `Sud.derive_plant_params()`
to compute a lumped thermal mass/specific-heat pair for `Pot`'s `M`/`C`, e.g.
in `scripts/demos/sud/demo_sud.py`, instead of hand-tuning them), and a flat
`schedule` list of steps, each either:
in `scripts/demos/sud/demo_sud.py`, instead of hand-tuning them), and a
`steps` list, each either:
- `"type": "heat"` — ramp to `temp` at `rate` (°C/min), or
- `"type": "hold"` — hold the current target for `duration` seconds (omit
for an immediate, zero-duration step).
- a ramp — `"ramp": {"rate": ..., "temp": ...}` — ramp to `temp` at `rate`
(°C/min), or
- a hold — `"hold": {"duration": ...}` — hold the current target for
`duration` seconds (omit/`0` for an immediate 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
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
`on_ratio * interval_time` of it. A step may also set
`user_wait_for_continue: true` (with an optional `user_message` to prompt
the user with) to pause for confirmation once the step completes, e.g. to
add malt or check gravity, instead of advancing immediately.
The top-level `default.step` object gives every field above (including the
nested `ramp`/`hold`/`stirrer` blocks) a default value; a step in `steps`
only needs to specify the fields it overrides — anything it omits is filled
in from `default.step`, recursively. A step is a ramp or a hold depending on
which of those two keys it specifies; the other is not defaulted in.
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
automatically: `components/sud.py`'s `Sud` tracks the current step and
automatically: `components/sud.py`'s `Sud` resolves each raw step against
`default.step` and tracks the current (resolved) step, while
`tasks/sud.py`'s `SudTask` drives the temperature controller's
`theta_soll`/`heatrate_soll` from `"heat"` steps (advancing once `theta_ist`
settles close to the target) and counts down `"hold"` steps' `duration`,
applying each step's stirrer timing as it goes (converting
`stirrer_time_on`/`stirrer_time_off` into a duty cycle/cycle time). It
exposes progress (current step, remaining hold time, state, any
`user_message`) on the `"Sud"` WebSocket channel and accepts
`{"Sud": {"Start": true}}` to begin the schedule and `{"Sud": {"Confirm":
true}}` to acknowledge a pause and move to the next step. Omit `sud` from
the config to run without schedule automation (manual
`theta_soll`/`heatrate_soll` control via the GUI, as before).
`theta_soll`/`heatrate_soll` from ramp steps (advancing once `theta_ist`
settles close to the target), counts down hold steps' `duration`, and
applies each step's `stirrer` block (`interval_time`/`on_ratio` map directly
onto the stirrer's cycle time/duty cycle). It exposes progress (current
step, remaining hold time, state, any `user_message`) on the `"Sud"`
WebSocket channel and accepts `{"Sud": {"Start": true}}` to begin the
schedule and `{"Sud": {"Confirm": true}}` to acknowledge a pause and move to
the next step. Omit `sud` from the config to run without schedule
automation (manual `theta_soll`/`heatrate_soll` control via the GUI, as
before).
## Logging & analysis
+30 -3
View File
@@ -20,6 +20,31 @@ class SudState(Enum):
DONE = 4
def _merge_defaults(default, override):
"""Deep-merges override onto default, recursing into nested dicts so
values missing from override fall back to default's at every level."""
merged = dict(default)
for key, value in override.items():
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
merged[key] = _merge_defaults(merged[key], value)
else:
merged[key] = value
return merged
def _build_step(defaults, raw_step):
"""Fills in a schedule step with default.step's values. A step is a
ramp or a hold depending on which of those keys it specifies; the other
is left out rather than synthesized from defaults."""
step = {}
for key in ('descr', 'user_message', 'user_wait_for_continue'):
step[key] = raw_step.get(key, defaults.get(key))
for key in ('ramp', 'hold'):
if key in raw_step:
step[key] = _merge_defaults(defaults.get(key, {}), raw_step[key])
return step
class Sud(AttributeChange):
def __init__(self, path):
AttributeChange.__init__(self)
@@ -28,7 +53,9 @@ class Sud(AttributeChange):
self.name = data.get('Name', '')
self.description = data.get('Description', '')
self.schedule = data['schedule']
step_defaults = data.get('default', {}).get('step', {})
self.schedule = [_build_step(step_defaults, raw) for raw in data['steps']]
self.pot_mass = data.get('pot_mass', 0)
self.pot_material = data.get('pot_material')
@@ -84,10 +111,10 @@ class Sud(AttributeChange):
return
next_step = self.schedule[self.index]
if next_step['type'] == 'heat':
if 'ramp' in next_step:
self.state = SudState.RAMPING
else:
self.hold_remaining = next_step.get('duration', 0)
self.hold_remaining = next_step['hold'].get('duration', 0)
self.state = SudState.HOLDING
self.user_message = next_step.get('user_message')
+17 -17
View File
@@ -27,7 +27,7 @@ if __name__ == '__main__':
},
"Heat": {
"kp": 0.08,
"ki": 0.008,
"ki": 0.02,
"kd": 0.0,
"kt": 1.5
}
@@ -38,7 +38,7 @@ if __name__ == '__main__':
plant_params = {
**sud.derive_plant_params(),
"L" : 0.2,
"Td" : 30
"Td" : 60
}
ctrl = TempController(dt, ctrl_params, plant_params, theta_amb)
@@ -53,29 +53,29 @@ if __name__ == '__main__':
heater.set_on_changed('power_eff', plant.set_power)
def apply_stirrer(step):
speed = step.get('stirrer_speed', 0)
on = step.get('stirrer_time_on', 0)
off = step.get('stirrer_time_off', 0)
cycle = on + off
if cycle > 0:
duty = on / cycle
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:
stirrer.set_cycle_time(interval_time)
stirrer.set_duty_cycle(on_ratio)
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_cycle_time(1.0)
stirrer.set_duty_cycle(1.0 if speed > 0 else 0.0)
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'])
if 'ramp' in step:
ctrl.set_theta_soll(step['ramp']['temp'])
ctrl.set_heatrate_soll(step['ramp']['rate'])
apply_stirrer(step)
def on_state_changed(state):
if state == SudState.DONE:
if state == SudState.WAIT_USER and sud.user_message:
print("Sud: {}".format(sud.user_message))
elif state == SudState.DONE:
stirrer.set_duty_cycle(1.0)
stirrer.set_speed(0)
+72 -44
View File
@@ -5,83 +5,111 @@
"pot_material" : "Edelstahl 18/10",
"grain_mass" : 5.21,
"water_mass" : 22,
"schedule":
"default":
{
"step" : {
"descr": "Put description here",
"user_message": "Put user message here",
"user_wait_for_continue": false,
"ramp": {
"rate": 1.0,
"temp": 0,
"stirrer": {
"speed": 50,
"interval_time": 0,
"on_ratio": 1.0
}
},
"hold": {
"duration": 0,
"stirrer": {
"speed": 30,
"interval_time": 60,
"on_ratio": 0.5
}
}
}
},
"steps":
[
{
"type": "heat",
"descr": "Heizen für Einmaischen",
"ramp": {
"rate": 1.8,
"temp": 57,
"stirrer_speed": 50,
"stirrer_time_on": 90,
"stirrer_time_off": 0
"stirrer": {
"speed": 75
}
}
},
{
"type": "hold",
"descr": "Einmaischen einfüllen",
"stirrer_speed": 0,
"user_message": "Bitte Malz einfüllen und bestätigen",
"user_wait_for_continue" : true
"user_wait_for_continue": true,
"hold": {
"stirrer": {
"speed": 0
}
}
},
{
"type": "hold",
"descr": "Einmaischen Rast",
"duration" : 200,
"stirrer_speed": 30,
"stirrer_time_on": 30,
"stirrer_time_off": 30
"hold": {
"duration": 2000
}
},
{
"type": "heat",
"descr": "Heizen Glucose-Rast",
"ramp": {
"rate": 1.0,
"temp": 63,
"stirrer_speed": 50,
"stirrer_time_on": 90,
"stirrer_time_off": 0
"temp": 63
}
},
{
"type": "hold",
"descr": "Halten Glucose-Rast",
"duration" : 400,
"stirrer_speed": 30,
"stirrer_time_on": 30,
"stirrer_time_off": 30
"hold": {
"duration": 4000,
"stirrer": {
"speed": 20,
"interval_time": 90,
"on_ratio": 0.8
}
}
},
{
"type": "heat",
"descr": "Heizen Verzuckerungs-Rast",
"ramp": {
"rate": 1.0,
"temp": 72,
"stirrer_speed": 50,
"stirrer_time_on": 90,
"stirrer_time_off": 0
"temp": 72
}
},
{
"type": "hold",
"descr": "Halten Verzuckerungs-Rast",
"duration" : 300,
"stirrer_speed": 30,
"stirrer_time_on": 30,
"stirrer_time_off": 30
"hold": {
"duration": 3000,
"stirrer": {
"speed": 35,
"interval_time": 120
}
}
},
{
"type": "heat",
"descr": "Heizen Abmaischen",
"ramp": {
"rate": 1.0,
"temp": 76,
"stirrer_speed": 50,
"stirrer_time_on": 90,
"stirrer_time_off": 0
"temp": 76
}
},
{
"type": "hold",
"descr": "Halten Abmaischen",
"duration" : 300,
"stirrer_speed": 30,
"stirrer_time_on": 30,
"stirrer_time_off": 30,
"user_wait_for_continue" : true
"user_message": "Quittieren zum Abmaischen",
"user_wait_for_continue": true,
"hold": {
"duration": 300,
"stirrer": {
"speed": 50
}
}
}
]
}
+18 -18
View File
@@ -19,34 +19,34 @@ class SudTask(ATask):
msg_handler.set_recv_handler(self.recv)
def apply_stirrer(self, step):
speed = step.get('stirrer_speed', 0)
on = step.get('stirrer_time_on', 0)
off = step.get('stirrer_time_off', 0)
cycle = on + off
if cycle > 0:
duty = on / cycle
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:
cycle = 1.0
duty = 1.0 if speed > 0 else 0.0
self.stirrer.set_cycle_time(cycle)
self.stirrer.set_duty_cycle(duty)
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 step['type'] == 'heat':
self.tc.set_theta_soll(step['temp'])
self.tc.set_heatrate_soll(step['rate'])
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': step['type'] if step else None,
'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': step.get('temp') if step else None,
'Rate': step.get('rate') if step else None,
'Duration': step.get('duration') 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,
}}))