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
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": <sud.json contents>}}`),
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
+4 -5
View File
@@ -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")))
+24 -18
View File
@@ -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
-1
View File
@@ -1,6 +1,5 @@
{
"ambient_temperature": 20,
"sud": "sude/sud_0010.json",
"Controller" : {
"dt": 0.1,
"sim_warp_factor": 10.0,
-1
View File
@@ -1,6 +1,5 @@
{
"ambient_temperature": 20,
"sud": "sude/sud_0010.json",
"Controller" : {
"dt": 1,
"sim_warp_factor": 10.0,
+4 -1
View File
@@ -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 = {
+29 -30
View File
@@ -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")