From 2c25b8be5082c698b4c6da2042a1b3e176de192b Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Sun, 21 Jun 2026 10:24:45 +0200 Subject: [PATCH] Sud: server always starts empty; sude/*.json is purely a client concern Several sude/*.json schedules can exist on disk; only one runs at a time, chosen by a client Load. Sud no longer takes a path at all - it starts empty (EMPTY_SUD) and the server config no longer names a specific schedule file, removing the implicit link to sud_0010.json in particular. Reading/writing sude/*.json is now entirely the client's job (e.g. the GUI's Load/Save file dialogs); the server's Sud only ever holds whatever was last Load()ed, in memory, until replaced or the server restarts. Updates demo_sud.py/demo_sud_save_load.py to construct an empty Sud() and load() a schedule explicitly, matching the new constructor. --- README.md | 41 +++++++++-------- brewpi/brewpi.py | 9 ++-- components/sud.py | 42 ++++++++++-------- config.json.sim | 1 - config.json.templ | 1 - scripts/demos/sud/demo_sud.py | 5 ++- scripts/demos/sud/demo_sud_save_load.py | 59 ++++++++++++------------- 7 files changed, 84 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index dd1ff02..a8f97b2 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,10 @@ components/ Pluggable building blocks behind factories: actor/ heater and stirrer drivers: simulated, a Hendi induction hob (serial protocol), or a Pololu 1376 stirrer motor controller - sud.py optional mash-schedule sequencer that steps - through a sude/*.json schedule and drives - the temperature controller from it + sud.py mash-schedule sequencer; starts out empty, + a client loads one of several sude/*.json + schedules onto it and it drives the + temperature controller from it tasks/ Async tasks (one per component) that poll hardware/sim state at a fixed interval and @@ -168,21 +169,25 @@ 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` 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 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). +The server always runs a `Sud` (`components/sud.py`), starting out empty (no +schedule) - `sude/` can hold several schedule files, and a client loads one +of them onto the running `Sud` (`{"Sud": {"Load": }}`), +kept purely in memory until replaced by another Load or the server +restarts; nothing is ever read from or written to `sude/*.json` by the +server itself, that's on the client (e.g. the GUI's File menu). `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 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. With no schedule loaded (or all its steps run), it just sits idle - +manual `theta_soll`/`heatrate_soll` control via the GUI works the same as +ever. ## Logging & analysis diff --git a/brewpi/brewpi.py b/brewpi/brewpi.py index cb813b0..64a10a0 100755 --- a/brewpi/brewpi.py +++ b/brewpi/brewpi.py @@ -70,11 +70,10 @@ if __name__ == '__main__': stirrer_task = StirrerTask(stirrer, DT_TASK, dispatcher.msgio_get("Stirrer")) taskmgr.add(stirrer_task) - # Mash schedule (optional) - sud_path = config.get('sud') - if sud_path: - sud = Sud(sud_path) - taskmgr.add(SudTask(sud, tc, stirrer, pot, DT, DT_TASK, dispatcher.msgio_get("Sud"))) + # Mash schedule - starts out empty; a client loads one of sude/*.json's + # several schedules onto it later (see components/sud.py's Sud). + sud = Sud() + taskmgr.add(SudTask(sud, tc, stirrer, pot, DT, DT_TASK, dispatcher.msgio_get("Sud"))) # Tracer taskmgr.add(TracerTask(sensor, heater, tc, trace_tc, DT_TASK_TRACER, dispatcher.msgio_get("Tracer"))) diff --git a/components/sud.py b/components/sud.py index d06bcc0..c7ea9df 100644 --- a/components/sud.py +++ b/components/sud.py @@ -1,4 +1,3 @@ -import json from enum import Enum from utils.value import AttributeChange @@ -46,16 +45,26 @@ def _build_step(number, defaults, raw_step): return step +EMPTY_SUD = { + 'Name': '', + 'Description': '', + 'pot_mass': 0, + 'pot_material': None, + 'steps': [], +} + + class Sud(AttributeChange): - def __init__(self, path): + def __init__(self): + """Starts out with no schedule loaded - one of potentially several + sude/*.json files on disk is brought in later via load(), kept + purely in memory (see load()) until replaced or the server + restarts.""" AttributeChange.__init__(self) - self.path = path - with open(path) as f: - data = json.load(f) - self._data = data + self._data = EMPTY_SUD (self.name, self.description, self.schedule, - self.pot_mass, self.pot_material) = self._parse_data(data) + self.pot_mass, self.pot_material) = self._parse_data(self._data) self._paused_from = None self._reset_run_state() @@ -88,20 +97,17 @@ class Sud(AttributeChange): self._paused_from = None def save(self): - """Returns the currently running sud.json document, for a client to - edit and hand back to load(). Purely in-memory - load() no longer - touches disk, so this is just whatever was last loaded (the file - given to __init__, or a later load()), not necessarily what's on - disk at self.path anymore.""" + """Returns the currently running sud.json document (EMPTY_SUD if + none has been loaded yet), for a client to edit and hand back to + load(). Purely in-memory - never read from or written to disk.""" return self._data def load(self, data): - """Replaces the running schedule with a new sud.json document (kept - in memory only - never written to disk, so the file originally - configured via self.path is never touched by this), resetting to - IDLE as if freshly loaded. A client wanting to persist a schedule - does so explicitly itself, e.g. by writing save()'s result wherever - it chooses. Refused while a brew is in progress, or if data is + """Replaces the running schedule with a new sud.json document, kept + in memory only (never written to disk - a client wanting to persist + a schedule does so explicitly itself, e.g. by writing save()'s + result to one of the sude/*.json files), resetting to IDLE as if + freshly loaded. Refused while a brew is in progress, or if data is malformed - in either case the current schedule is left untouched.""" if self.state not in (SudState.IDLE, SudState.DONE): return False diff --git a/config.json.sim b/config.json.sim index 6ce925d..6aa2d9d 100755 --- a/config.json.sim +++ b/config.json.sim @@ -1,6 +1,5 @@ { "ambient_temperature": 20, - "sud": "sude/sud_0010.json", "Controller" : { "dt": 0.1, "sim_warp_factor": 10.0, diff --git a/config.json.templ b/config.json.templ index 41fb478..dc58ee1 100644 --- a/config.json.templ +++ b/config.json.templ @@ -1,6 +1,5 @@ { "ambient_temperature": 20, - "sud": "sude/sud_0010.json", "Controller" : { "dt": 1, "sim_warp_factor": 10.0, diff --git a/scripts/demos/sud/demo_sud.py b/scripts/demos/sud/demo_sud.py index 1d2f061..d3f8ee3 100644 --- a/scripts/demos/sud/demo_sud.py +++ b/scripts/demos/sud/demo_sud.py @@ -1,3 +1,4 @@ +import json import numpy as np from matplotlib.pyplot import plot, step, figure, subplot, grid, show, legend, yticks from components.sud import Sud, SudState @@ -33,7 +34,9 @@ if __name__ == '__main__': } } - sud = Sud("sude/sud_0010.json") + sud = Sud() + with open("sude/sud_0010.json") as f: + sud.load(json.load(f)) first_step = sud.schedule[0] plant_params = { diff --git a/scripts/demos/sud/demo_sud_save_load.py b/scripts/demos/sud/demo_sud_save_load.py index 7f756ab..91cb0b4 100644 --- a/scripts/demos/sud/demo_sud_save_load.py +++ b/scripts/demos/sud/demo_sud_save_load.py @@ -1,45 +1,44 @@ import json -import shutil -import tempfile -from pathlib import Path from components.sud import Sud, SudState # Console-only walkthrough of Sud.save()/load() (no plot - there's no -# numeric data here, just JSON plumbing). Runs against a scratch copy of a -# real sud.json so sude/sud_0010.json itself is never touched. +# numeric data here, just JSON plumbing). load()/save() are purely +# in-memory - Sud itself never reads or writes anything under sude/. if __name__ == '__main__': - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "sud.json" - shutil.copy("sude/sud_0010.json", path) + sud = Sud() + print(f"Starts out empty: '{sud.name}', {len(sud.schedule)} steps") - sud = Sud(str(path)) - print(f"Loaded '{sud.name}', {len(sud.schedule)} steps") + with open("sude/sud_0010.json") as f: + doc = json.load(f) + assert sud.load(doc) is True + print(f"Loaded '{sud.name}', {len(sud.schedule)} steps") - doc = sud.save() - assert doc['Name'] == sud.name - print("save() returned the on-disk document") + saved = sud.save() + assert saved['Name'] == sud.name + print("save() returned the running document") - sud.state = SudState.RAMPING - assert sud.load(doc) is False - print("load() refused while RAMPING") + sud.state = SudState.RAMPING + assert sud.load(saved) is False + print("load() refused while RAMPING") - sud.state = SudState.IDLE - doc['Name'] = 'Sud-Renamed' - doc['steps'][0]['descr'] = 'Edited step' - assert sud.load(doc) is True - assert sud.name == 'Sud-Renamed' - assert sud.schedule[0]['descr'] == 'Edited step' - assert sud.state == SudState.IDLE - print("load() applied the edit and reset to IDLE") + sud.state = SudState.IDLE + saved['Name'] = 'Sud-Renamed' + saved['steps'][0]['descr'] = 'Edited step' + assert sud.load(saved) is True + assert sud.name == 'Sud-Renamed' + assert sud.schedule[0]['descr'] == 'Edited step' + assert sud.state == SudState.IDLE + print("load() applied the edit, purely in memory") - on_disk = json.loads(path.read_text()) - assert on_disk['Name'] == 'Sud-Renamed' - print("load() persisted the edit to disk") + with open("sude/sud_0010.json") as f: + on_disk = json.load(f) + assert on_disk['Name'] != 'Sud-Renamed' + print("sude/sud_0010.json on disk is untouched") - assert sud.load({'Name': 'broken'}) is False - assert sud.name == 'Sud-Renamed' - print("load() rejected malformed data, left schedule untouched") + assert sud.load({'Name': 'broken'}) is False + assert sud.name == 'Sud-Renamed' + print("load() rejected malformed data, left schedule untouched") print("All sud save/load checks passed")