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:
@@ -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
@@ -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
@@ -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,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,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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user