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`, Files under `sude/` describe a brew's mash schedule ("Sud"): `pot_mass`,
`pot_material`, `grain_mass`, `water_mass` (used by `Sud.derive_plant_params()` `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. 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 in `scripts/demos/sud/demo_sud.py`, instead of hand-tuning them), and a
`schedule` list of steps, each either: `steps` list, each either:
- `"type": "heat"` — ramp to `temp` at `rate` (°C/min), or - a ramp — `"ramp": {"rate": ..., "temp": ...}` — ramp to `temp` at `rate`
- `"type": "hold"` — hold the current target for `duration` seconds (omit (°C/min), or
for an immediate, zero-duration step). - 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`, Both `ramp` and `hold` carry a `stirrer` block (`speed`, `interval_time`,
`stirrer_time_on`/`stirrer_time_off` in seconds — `time_off: 0` means `on_ratio`): `interval_time: 0` runs the stirrer continuously at `speed`;
continuous), and may set `user_wait_for_continue: true` (with an optional `interval_time > 0` pulses it on a period of `interval_time` seconds, on for
`user_message`) to pause for confirmation once the step completes, e.g. to `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. 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 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 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 `tasks/sud.py`'s `SudTask` drives the temperature controller's
`theta_soll`/`heatrate_soll` from `"heat"` steps (advancing once `theta_ist` `theta_soll`/`heatrate_soll` from ramp steps (advancing once `theta_ist`
settles close to the target) and counts down `"hold"` steps' `duration`, settles close to the target), counts down hold steps' `duration`, and
applying each step's stirrer timing as it goes (converting applies each step's `stirrer` block (`interval_time`/`on_ratio` map directly
`stirrer_time_on`/`stirrer_time_off` into a duty cycle/cycle time). It onto the stirrer's cycle time/duty cycle). It exposes progress (current
exposes progress (current step, remaining hold time, state, any step, remaining hold time, state, any `user_message`) 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 pause and move to
true}}` to acknowledge a pause and move to the next step. Omit `sud` from the next step. Omit `sud` from the config to run without schedule
the config to run without schedule automation (manual automation (manual `theta_soll`/`heatrate_soll` control via the GUI, as
`theta_soll`/`heatrate_soll` control via the GUI, as before). before).
## Logging & analysis ## Logging & analysis
+30 -3
View File
@@ -20,6 +20,31 @@ class SudState(Enum):
DONE = 4 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): class Sud(AttributeChange):
def __init__(self, path): def __init__(self, path):
AttributeChange.__init__(self) AttributeChange.__init__(self)
@@ -28,7 +53,9 @@ class Sud(AttributeChange):
self.name = data.get('Name', '') self.name = data.get('Name', '')
self.description = data.get('Description', '') 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_mass = data.get('pot_mass', 0)
self.pot_material = data.get('pot_material') self.pot_material = data.get('pot_material')
@@ -84,10 +111,10 @@ class Sud(AttributeChange):
return return
next_step = self.schedule[self.index] next_step = self.schedule[self.index]
if next_step['type'] == 'heat': if 'ramp' in next_step:
self.state = SudState.RAMPING self.state = SudState.RAMPING
else: else:
self.hold_remaining = next_step.get('duration', 0) self.hold_remaining = next_step['hold'].get('duration', 0)
self.state = SudState.HOLDING self.state = SudState.HOLDING
self.user_message = next_step.get('user_message') self.user_message = next_step.get('user_message')
+17 -17
View File
@@ -27,7 +27,7 @@ if __name__ == '__main__':
}, },
"Heat": { "Heat": {
"kp": 0.08, "kp": 0.08,
"ki": 0.008, "ki": 0.02,
"kd": 0.0, "kd": 0.0,
"kt": 1.5 "kt": 1.5
} }
@@ -38,7 +38,7 @@ if __name__ == '__main__':
plant_params = { plant_params = {
**sud.derive_plant_params(), **sud.derive_plant_params(),
"L" : 0.2, "L" : 0.2,
"Td" : 30 "Td" : 60
} }
ctrl = TempController(dt, ctrl_params, plant_params, theta_amb) 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) heater.set_on_changed('power_eff', plant.set_power)
def apply_stirrer(step): def apply_stirrer(step):
speed = step.get('stirrer_speed', 0) stirrer_cfg = step.get('ramp', step.get('hold', {})).get('stirrer', {})
on = step.get('stirrer_time_on', 0) speed = stirrer_cfg.get('speed', 0)
off = step.get('stirrer_time_off', 0) interval_time = stirrer_cfg.get('interval_time', 0)
cycle = on + off on_ratio = stirrer_cfg.get('on_ratio', 1.0)
if cycle > 0: if interval_time > 0:
duty = on / cycle stirrer.set_cycle_time(interval_time)
stirrer.set_duty_cycle(on_ratio)
else: else:
cycle = 1.0 stirrer.set_cycle_time(1.0)
duty = 1.0 if speed > 0 else 0.0 stirrer.set_duty_cycle(1.0 if speed > 0 else 0.0)
stirrer.set_cycle_time(cycle)
stirrer.set_duty_cycle(duty)
stirrer.set_speed(speed) stirrer.set_speed(speed)
def on_step_changed(step): def on_step_changed(step):
if step is not None: if step is not None:
if step['type'] == 'heat': if 'ramp' in step:
ctrl.set_theta_soll(step['temp']) ctrl.set_theta_soll(step['ramp']['temp'])
ctrl.set_heatrate_soll(step['rate']) ctrl.set_heatrate_soll(step['ramp']['rate'])
apply_stirrer(step) apply_stirrer(step)
def on_state_changed(state): 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_duty_cycle(1.0)
stirrer.set_speed(0) stirrer.set_speed(0)
+72 -44
View File
@@ -5,83 +5,111 @@
"pot_material" : "Edelstahl 18/10", "pot_material" : "Edelstahl 18/10",
"grain_mass" : 5.21, "grain_mass" : 5.21,
"water_mass" : 22, "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", "descr": "Heizen für Einmaischen",
"ramp": {
"rate": 1.8, "rate": 1.8,
"temp": 57, "temp": 57,
"stirrer_speed": 50, "stirrer": {
"stirrer_time_on": 90, "speed": 75
"stirrer_time_off": 0 }
}
}, },
{ {
"type": "hold",
"descr": "Einmaischen einfüllen", "descr": "Einmaischen einfüllen",
"stirrer_speed": 0,
"user_message": "Bitte Malz einfüllen und bestätigen", "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", "descr": "Einmaischen Rast",
"duration" : 200, "hold": {
"stirrer_speed": 30, "duration": 2000
"stirrer_time_on": 30, }
"stirrer_time_off": 30
}, },
{ {
"type": "heat",
"descr": "Heizen Glucose-Rast", "descr": "Heizen Glucose-Rast",
"ramp": {
"rate": 1.0, "rate": 1.0,
"temp": 63, "temp": 63
"stirrer_speed": 50, }
"stirrer_time_on": 90,
"stirrer_time_off": 0
}, },
{ {
"type": "hold",
"descr": "Halten Glucose-Rast", "descr": "Halten Glucose-Rast",
"duration" : 400, "hold": {
"stirrer_speed": 30, "duration": 4000,
"stirrer_time_on": 30, "stirrer": {
"stirrer_time_off": 30 "speed": 20,
"interval_time": 90,
"on_ratio": 0.8
}
}
}, },
{ {
"type": "heat",
"descr": "Heizen Verzuckerungs-Rast", "descr": "Heizen Verzuckerungs-Rast",
"ramp": {
"rate": 1.0, "rate": 1.0,
"temp": 72, "temp": 72
"stirrer_speed": 50, }
"stirrer_time_on": 90,
"stirrer_time_off": 0
}, },
{ {
"type": "hold",
"descr": "Halten Verzuckerungs-Rast", "descr": "Halten Verzuckerungs-Rast",
"duration" : 300, "hold": {
"stirrer_speed": 30, "duration": 3000,
"stirrer_time_on": 30, "stirrer": {
"stirrer_time_off": 30 "speed": 35,
"interval_time": 120
}
}
}, },
{ {
"type": "heat",
"descr": "Heizen Abmaischen", "descr": "Heizen Abmaischen",
"ramp": {
"rate": 1.0, "rate": 1.0,
"temp": 76, "temp": 76
"stirrer_speed": 50, }
"stirrer_time_on": 90,
"stirrer_time_off": 0
}, },
{ {
"type": "hold",
"descr": "Halten Abmaischen", "descr": "Halten Abmaischen",
"duration" : 300, "user_message": "Quittieren zum Abmaischen",
"stirrer_speed": 30, "user_wait_for_continue": true,
"stirrer_time_on": 30, "hold": {
"stirrer_time_off": 30, "duration": 300,
"user_wait_for_continue" : true "stirrer": {
"speed": 50
}
}
} }
] ]
} }
+18 -18
View File
@@ -19,34 +19,34 @@ class SudTask(ATask):
msg_handler.set_recv_handler(self.recv) msg_handler.set_recv_handler(self.recv)
def apply_stirrer(self, step): def apply_stirrer(self, step):
speed = step.get('stirrer_speed', 0) stirrer_cfg = step.get('ramp', step.get('hold', {})).get('stirrer', {})
on = step.get('stirrer_time_on', 0) speed = stirrer_cfg.get('speed', 0)
off = step.get('stirrer_time_off', 0) interval_time = stirrer_cfg.get('interval_time', 0)
cycle = on + off on_ratio = stirrer_cfg.get('on_ratio', 1.0)
if cycle > 0: if interval_time > 0:
duty = on / cycle self.stirrer.set_cycle_time(interval_time)
self.stirrer.set_duty_cycle(on_ratio)
else: else:
cycle = 1.0 self.stirrer.set_cycle_time(1.0)
duty = 1.0 if speed > 0 else 0.0 self.stirrer.set_duty_cycle(1.0 if speed > 0 else 0.0)
self.stirrer.set_cycle_time(cycle)
self.stirrer.set_duty_cycle(duty)
self.stirrer.set_speed(speed) self.stirrer.set_speed(speed)
def on_step_changed(self, step): 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 is not None:
if step['type'] == 'heat': if ramp is not None:
self.tc.set_theta_soll(step['temp']) self.tc.set_theta_soll(ramp['temp'])
self.tc.set_heatrate_soll(step['rate']) self.tc.set_heatrate_soll(ramp['rate'])
self.apply_stirrer(step) self.apply_stirrer(step)
asyncio.create_task(self.send({'Step': { asyncio.create_task(self.send({'Step': {
'Index': self.sud.index, '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, 'Descr': step.get('descr') if step else None,
'Temp': step.get('temp') if step else None, 'Temp': ramp.get('temp') if ramp else None,
'Rate': step.get('rate') if step else None, 'Rate': ramp.get('rate') if ramp else None,
'Duration': step.get('duration') if step else None, 'Duration': hold.get('duration') if hold else None,
'WaitForUser': step.get('user_wait_for_continue', False) if step else None, 'WaitForUser': step.get('user_wait_for_continue', False) if step else None,
}})) }}))