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.
This commit is contained in:
2026-06-21 10:24:45 +02:00
parent 3ed2961dce
commit 2c25b8be50
7 changed files with 84 additions and 74 deletions
+23 -18
View File
@@ -38,9 +38,10 @@ components/ Pluggable building blocks behind factories:
actor/ heater and stirrer drivers: simulated, a actor/ heater and stirrer drivers: simulated, a
Hendi induction hob (serial protocol), or a Hendi induction hob (serial protocol), or a
Pololu 1376 stirrer motor controller Pololu 1376 stirrer motor controller
sud.py optional mash-schedule sequencer that steps sud.py mash-schedule sequencer; starts out empty,
through a sude/*.json schedule and drives a client loads one of several sude/*.json
the temperature controller from it schedules onto it and it drives the
temperature controller from it
tasks/ Async tasks (one per component) that poll tasks/ Async tasks (one per component) that poll
hardware/sim state at a fixed interval and 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 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. 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 The server always runs a `Sud` (`components/sud.py`), starting out empty (no
`config.json.sim`/`config.json.templ`) to have the server step through it schedule) - `sude/` can hold several schedule files, and a client loads one
automatically: `components/sud.py`'s `Sud` resolves each raw step against of them onto the running `Sud` (`{"Sud": {"Load": <sud.json contents>}}`),
`default.step` and tracks the current (resolved) step, while kept purely in memory until replaced by another Load or the server
`tasks/sud.py`'s `SudTask` drives the temperature controller's restarts; nothing is ever read from or written to `sude/*.json` by the
`theta_soll`/`heatrate_soll` from ramp steps (advancing once `theta_ist` server itself, that's on the client (e.g. the GUI's File menu). `Sud`
settles close to the target), counts down hold steps' `duration`, and resolves each raw step against `default.step` and tracks the current
applies each step's `stirrer` block (`interval_time`/`on_ratio` map directly (resolved) step, while `tasks/sud.py`'s `SudTask` drives the temperature
onto the stirrer's cycle time/duty cycle). It exposes progress (current controller's `theta_soll`/`heatrate_soll` from ramp steps (advancing once
step, remaining hold time, state, any `user_message`) on the `"Sud"` `theta_ist` settles close to the target), counts down hold steps'
WebSocket channel and accepts `{"Sud": {"Start": true}}` to begin the `duration`, and applies each step's `stirrer` block (`interval_time`/
schedule and `{"Sud": {"Confirm": true}}` to acknowledge a pause and move to `on_ratio` map directly onto the stirrer's cycle time/duty cycle). It
the next step. Omit `sud` from the config to run without schedule exposes progress (current step, remaining hold time, state, any
automation (manual `theta_soll`/`heatrate_soll` control via the GUI, as `user_message`) on the `"Sud"` WebSocket channel and accepts
before). `{"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 ## Logging & analysis
+4 -5
View File
@@ -70,11 +70,10 @@ if __name__ == '__main__':
stirrer_task = StirrerTask(stirrer, DT_TASK, dispatcher.msgio_get("Stirrer")) stirrer_task = StirrerTask(stirrer, DT_TASK, dispatcher.msgio_get("Stirrer"))
taskmgr.add(stirrer_task) taskmgr.add(stirrer_task)
# Mash schedule (optional) # Mash schedule - starts out empty; a client loads one of sude/*.json's
sud_path = config.get('sud') # several schedules onto it later (see components/sud.py's Sud).
if sud_path: sud = Sud()
sud = Sud(sud_path) taskmgr.add(SudTask(sud, tc, stirrer, pot, DT, DT_TASK, dispatcher.msgio_get("Sud")))
taskmgr.add(SudTask(sud, tc, stirrer, pot, DT, DT_TASK, dispatcher.msgio_get("Sud")))
# Tracer # Tracer
taskmgr.add(TracerTask(sensor, heater, tc, trace_tc, DT_TASK_TRACER, dispatcher.msgio_get("Tracer"))) taskmgr.add(TracerTask(sensor, heater, tc, trace_tc, DT_TASK_TRACER, dispatcher.msgio_get("Tracer")))
+24 -18
View File
@@ -1,4 +1,3 @@
import json
from enum import Enum from enum import Enum
from utils.value import AttributeChange from utils.value import AttributeChange
@@ -46,16 +45,26 @@ def _build_step(number, defaults, raw_step):
return step return step
EMPTY_SUD = {
'Name': '',
'Description': '',
'pot_mass': 0,
'pot_material': None,
'steps': [],
}
class Sud(AttributeChange): 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) AttributeChange.__init__(self)
self.path = path self._data = EMPTY_SUD
with open(path) as f:
data = json.load(f)
self._data = data
(self.name, self.description, self.schedule, (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._paused_from = None
self._reset_run_state() self._reset_run_state()
@@ -88,20 +97,17 @@ class Sud(AttributeChange):
self._paused_from = None self._paused_from = None
def save(self): def save(self):
"""Returns the currently running sud.json document, for a client to """Returns the currently running sud.json document (EMPTY_SUD if
edit and hand back to load(). Purely in-memory - load() no longer none has been loaded yet), for a client to edit and hand back to
touches disk, so this is just whatever was last loaded (the file load(). Purely in-memory - never read from or written to disk."""
given to __init__, or a later load()), not necessarily what's on
disk at self.path anymore."""
return self._data return self._data
def load(self, data): def load(self, data):
"""Replaces the running schedule with a new sud.json document (kept """Replaces the running schedule with a new sud.json document, kept
in memory only - never written to disk, so the file originally in memory only (never written to disk - a client wanting to persist
configured via self.path is never touched by this), resetting to a schedule does so explicitly itself, e.g. by writing save()'s
IDLE as if freshly loaded. A client wanting to persist a schedule result to one of the sude/*.json files), resetting to IDLE as if
does so explicitly itself, e.g. by writing save()'s result wherever freshly loaded. Refused while a brew is in progress, or if data is
it chooses. Refused while a brew is in progress, or if data is
malformed - in either case the current schedule is left untouched.""" malformed - in either case the current schedule is left untouched."""
if self.state not in (SudState.IDLE, SudState.DONE): if self.state not in (SudState.IDLE, SudState.DONE):
return False return False
-1
View File
@@ -1,6 +1,5 @@
{ {
"ambient_temperature": 20, "ambient_temperature": 20,
"sud": "sude/sud_0010.json",
"Controller" : { "Controller" : {
"dt": 0.1, "dt": 0.1,
"sim_warp_factor": 10.0, "sim_warp_factor": 10.0,
-1
View File
@@ -1,6 +1,5 @@
{ {
"ambient_temperature": 20, "ambient_temperature": 20,
"sud": "sude/sud_0010.json",
"Controller" : { "Controller" : {
"dt": 1, "dt": 1,
"sim_warp_factor": 10.0, "sim_warp_factor": 10.0,
+4 -1
View File
@@ -1,3 +1,4 @@
import json
import numpy as np import numpy as np
from matplotlib.pyplot import plot, step, figure, subplot, grid, show, legend, yticks from matplotlib.pyplot import plot, step, figure, subplot, grid, show, legend, yticks
from components.sud import Sud, SudState 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] first_step = sud.schedule[0]
plant_params = { plant_params = {
+29 -30
View File
@@ -1,45 +1,44 @@
import json import json
import shutil
import tempfile
from pathlib import Path
from components.sud import Sud, SudState from components.sud import Sud, SudState
# Console-only walkthrough of Sud.save()/load() (no plot - there's no # 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 # numeric data here, just JSON plumbing). load()/save() are purely
# real sud.json so sude/sud_0010.json itself is never touched. # in-memory - Sud itself never reads or writes anything under sude/.
if __name__ == '__main__': if __name__ == '__main__':
with tempfile.TemporaryDirectory() as tmp: sud = Sud()
path = Path(tmp) / "sud.json" print(f"Starts out empty: '{sud.name}', {len(sud.schedule)} steps")
shutil.copy("sude/sud_0010.json", path)
sud = Sud(str(path)) with open("sude/sud_0010.json") as f:
print(f"Loaded '{sud.name}', {len(sud.schedule)} steps") doc = json.load(f)
assert sud.load(doc) is True
print(f"Loaded '{sud.name}', {len(sud.schedule)} steps")
doc = sud.save() saved = sud.save()
assert doc['Name'] == sud.name assert saved['Name'] == sud.name
print("save() returned the on-disk document") print("save() returned the running document")
sud.state = SudState.RAMPING sud.state = SudState.RAMPING
assert sud.load(doc) is False assert sud.load(saved) is False
print("load() refused while RAMPING") print("load() refused while RAMPING")
sud.state = SudState.IDLE sud.state = SudState.IDLE
doc['Name'] = 'Sud-Renamed' saved['Name'] = 'Sud-Renamed'
doc['steps'][0]['descr'] = 'Edited step' saved['steps'][0]['descr'] = 'Edited step'
assert sud.load(doc) is True assert sud.load(saved) is True
assert sud.name == 'Sud-Renamed' assert sud.name == 'Sud-Renamed'
assert sud.schedule[0]['descr'] == 'Edited step' assert sud.schedule[0]['descr'] == 'Edited step'
assert sud.state == SudState.IDLE assert sud.state == SudState.IDLE
print("load() applied the edit and reset to IDLE") print("load() applied the edit, purely in memory")
on_disk = json.loads(path.read_text()) with open("sude/sud_0010.json") as f:
assert on_disk['Name'] == 'Sud-Renamed' on_disk = json.load(f)
print("load() persisted the edit to disk") assert on_disk['Name'] != 'Sud-Renamed'
print("sude/sud_0010.json on disk is untouched")
assert sud.load({'Name': 'broken'}) is False assert sud.load({'Name': 'broken'}) is False
assert sud.name == 'Sud-Renamed' assert sud.name == 'Sud-Renamed'
print("load() rejected malformed data, left schedule untouched") print("load() rejected malformed data, left schedule untouched")
print("All sud save/load checks passed") print("All sud save/load checks passed")