From 123318ac85e3ab2d45914fa83e312a972706e52d Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Sat, 20 Jun 2026 07:20:52 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01CgR9tPaSzFkAwRAUyeaaCD --- README.md | 50 ++++++++------ components/sud.py | 33 ++++++++- scripts/demos/sud/demo_sud.py | 34 ++++----- sude/sud_0010.json | 126 +++++++++++++++++++++------------- tasks/sud.py | 36 +++++----- 5 files changed, 172 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 2d151ef..4c0e659 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/components/sud.py b/components/sud.py index 6dde2c2..645adbd 100644 --- a/components/sud.py +++ b/components/sud.py @@ -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') diff --git a/scripts/demos/sud/demo_sud.py b/scripts/demos/sud/demo_sud.py index 157a8c5..f0041a7 100644 --- a/scripts/demos/sud/demo_sud.py +++ b/scripts/demos/sud/demo_sud.py @@ -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) diff --git a/sude/sud_0010.json b/sude/sud_0010.json index 7af1a18..2d88d9b 100644 --- a/sude/sud_0010.json +++ b/sude/sud_0010.json @@ -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", - "rate": 1.8, - "temp": 57, - "stirrer_speed": 50, - "stirrer_time_on": 90, - "stirrer_time_off": 0 + "ramp": { + "rate": 1.8, + "temp": 57, + "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", - "rate": 1.0, - "temp": 63, - "stirrer_speed": 50, - "stirrer_time_on": 90, - "stirrer_time_off": 0 + "ramp": { + "rate": 1.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", - "rate": 1.0, - "temp": 72, - "stirrer_speed": 50, - "stirrer_time_on": 90, - "stirrer_time_off": 0 + "ramp": { + "rate": 1.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", - "rate": 1.0, - "temp": 76, - "stirrer_speed": 50, - "stirrer_time_on": 90, - "stirrer_time_off": 0 + "ramp": { + "rate": 1.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 + } + } } ] } diff --git a/tasks/sud.py b/tasks/sud.py index 0ddf9a2..ab9f4bf 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -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, }}))