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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgR9tPaSzFkAwRAUyeaaCD
This commit is contained in:
@@ -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`,
|
Files under `sude/` describe a brew's mash schedule ("Sud"): `pot_mass`,
|
||||||
`pot_material`, `grain_mass`, `water_mass` (used by `Sud.derive_plant_params()`
|
`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.
|
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
|
in `scripts/demos/sud/demo_sud.py`, instead of hand-tuning them), and a
|
||||||
`schedule` list of steps, each either:
|
`steps` list, each either:
|
||||||
|
|
||||||
- `"type": "heat"` — ramp to `temp` at `rate` (°C/min), or
|
- a ramp — `"ramp": {"rate": ..., "temp": ...}` — ramp to `temp` at `rate`
|
||||||
- `"type": "hold"` — hold the current target for `duration` seconds (omit
|
(°C/min), or
|
||||||
for an immediate, zero-duration step).
|
- 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`,
|
Both `ramp` and `hold` carry a `stirrer` block (`speed`, `interval_time`,
|
||||||
`stirrer_time_on`/`stirrer_time_off` in seconds — `time_off: 0` means
|
`on_ratio`): `interval_time: 0` runs the stirrer continuously at `speed`;
|
||||||
continuous), and may set `user_wait_for_continue: true` (with an optional
|
`interval_time > 0` pulses it on a period of `interval_time` seconds, on for
|
||||||
`user_message`) to pause for confirmation once the step completes, e.g. to
|
`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.
|
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
|
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
|
`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
|
`tasks/sud.py`'s `SudTask` drives the temperature controller's
|
||||||
`theta_soll`/`heatrate_soll` from `"heat"` steps (advancing once `theta_ist`
|
`theta_soll`/`heatrate_soll` from ramp steps (advancing once `theta_ist`
|
||||||
settles close to the target) and counts down `"hold"` steps' `duration`,
|
settles close to the target), counts down hold steps' `duration`, and
|
||||||
applying each step's stirrer timing as it goes (converting
|
applies each step's `stirrer` block (`interval_time`/`on_ratio` map directly
|
||||||
`stirrer_time_on`/`stirrer_time_off` into a duty cycle/cycle time). It
|
onto the stirrer's cycle time/duty cycle). It exposes progress (current
|
||||||
exposes progress (current step, remaining hold time, state, any
|
step, remaining hold time, state, any `user_message`) on the `"Sud"`
|
||||||
`user_message`) on the `"Sud"` WebSocket channel and accepts
|
WebSocket channel and accepts `{"Sud": {"Start": true}}` to begin the
|
||||||
`{"Sud": {"Start": true}}` to begin the schedule and `{"Sud": {"Confirm":
|
schedule and `{"Sud": {"Confirm": true}}` to acknowledge a pause and move to
|
||||||
true}}` to acknowledge a pause and move to the next step. Omit `sud` from
|
the next step. Omit `sud` from the config to run without schedule
|
||||||
the config to run without schedule automation (manual
|
automation (manual `theta_soll`/`heatrate_soll` control via the GUI, as
|
||||||
`theta_soll`/`heatrate_soll` control via the GUI, as before).
|
before).
|
||||||
|
|
||||||
## Logging & analysis
|
## Logging & analysis
|
||||||
|
|
||||||
|
|||||||
+30
-3
@@ -20,6 +20,31 @@ class SudState(Enum):
|
|||||||
DONE = 4
|
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):
|
class Sud(AttributeChange):
|
||||||
def __init__(self, path):
|
def __init__(self, path):
|
||||||
AttributeChange.__init__(self)
|
AttributeChange.__init__(self)
|
||||||
@@ -28,7 +53,9 @@ class Sud(AttributeChange):
|
|||||||
|
|
||||||
self.name = data.get('Name', '')
|
self.name = data.get('Name', '')
|
||||||
self.description = data.get('Description', '')
|
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_mass = data.get('pot_mass', 0)
|
||||||
self.pot_material = data.get('pot_material')
|
self.pot_material = data.get('pot_material')
|
||||||
@@ -84,10 +111,10 @@ class Sud(AttributeChange):
|
|||||||
return
|
return
|
||||||
|
|
||||||
next_step = self.schedule[self.index]
|
next_step = self.schedule[self.index]
|
||||||
if next_step['type'] == 'heat':
|
if 'ramp' in next_step:
|
||||||
self.state = SudState.RAMPING
|
self.state = SudState.RAMPING
|
||||||
else:
|
else:
|
||||||
self.hold_remaining = next_step.get('duration', 0)
|
self.hold_remaining = next_step['hold'].get('duration', 0)
|
||||||
self.state = SudState.HOLDING
|
self.state = SudState.HOLDING
|
||||||
|
|
||||||
self.user_message = next_step.get('user_message')
|
self.user_message = next_step.get('user_message')
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ if __name__ == '__main__':
|
|||||||
},
|
},
|
||||||
"Heat": {
|
"Heat": {
|
||||||
"kp": 0.08,
|
"kp": 0.08,
|
||||||
"ki": 0.008,
|
"ki": 0.02,
|
||||||
"kd": 0.0,
|
"kd": 0.0,
|
||||||
"kt": 1.5
|
"kt": 1.5
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ if __name__ == '__main__':
|
|||||||
plant_params = {
|
plant_params = {
|
||||||
**sud.derive_plant_params(),
|
**sud.derive_plant_params(),
|
||||||
"L" : 0.2,
|
"L" : 0.2,
|
||||||
"Td" : 30
|
"Td" : 60
|
||||||
}
|
}
|
||||||
|
|
||||||
ctrl = TempController(dt, ctrl_params, plant_params, theta_amb)
|
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)
|
heater.set_on_changed('power_eff', plant.set_power)
|
||||||
|
|
||||||
def apply_stirrer(step):
|
def apply_stirrer(step):
|
||||||
speed = step.get('stirrer_speed', 0)
|
stirrer_cfg = step.get('ramp', step.get('hold', {})).get('stirrer', {})
|
||||||
on = step.get('stirrer_time_on', 0)
|
speed = stirrer_cfg.get('speed', 0)
|
||||||
off = step.get('stirrer_time_off', 0)
|
interval_time = stirrer_cfg.get('interval_time', 0)
|
||||||
cycle = on + off
|
on_ratio = stirrer_cfg.get('on_ratio', 1.0)
|
||||||
if cycle > 0:
|
if interval_time > 0:
|
||||||
duty = on / cycle
|
stirrer.set_cycle_time(interval_time)
|
||||||
|
stirrer.set_duty_cycle(on_ratio)
|
||||||
else:
|
else:
|
||||||
cycle = 1.0
|
stirrer.set_cycle_time(1.0)
|
||||||
duty = 1.0 if speed > 0 else 0.0
|
stirrer.set_duty_cycle(1.0 if speed > 0 else 0.0)
|
||||||
|
|
||||||
stirrer.set_cycle_time(cycle)
|
|
||||||
stirrer.set_duty_cycle(duty)
|
|
||||||
stirrer.set_speed(speed)
|
stirrer.set_speed(speed)
|
||||||
|
|
||||||
def on_step_changed(step):
|
def on_step_changed(step):
|
||||||
if step is not None:
|
if step is not None:
|
||||||
if step['type'] == 'heat':
|
if 'ramp' in step:
|
||||||
ctrl.set_theta_soll(step['temp'])
|
ctrl.set_theta_soll(step['ramp']['temp'])
|
||||||
ctrl.set_heatrate_soll(step['rate'])
|
ctrl.set_heatrate_soll(step['ramp']['rate'])
|
||||||
apply_stirrer(step)
|
apply_stirrer(step)
|
||||||
|
|
||||||
def on_state_changed(state):
|
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_duty_cycle(1.0)
|
||||||
stirrer.set_speed(0)
|
stirrer.set_speed(0)
|
||||||
|
|
||||||
|
|||||||
+72
-44
@@ -5,83 +5,111 @@
|
|||||||
"pot_material" : "Edelstahl 18/10",
|
"pot_material" : "Edelstahl 18/10",
|
||||||
"grain_mass" : 5.21,
|
"grain_mass" : 5.21,
|
||||||
"water_mass" : 22,
|
"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",
|
"descr": "Heizen für Einmaischen",
|
||||||
|
"ramp": {
|
||||||
"rate": 1.8,
|
"rate": 1.8,
|
||||||
"temp": 57,
|
"temp": 57,
|
||||||
"stirrer_speed": 50,
|
"stirrer": {
|
||||||
"stirrer_time_on": 90,
|
"speed": 75
|
||||||
"stirrer_time_off": 0
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "hold",
|
|
||||||
"descr": "Einmaischen einfüllen",
|
"descr": "Einmaischen einfüllen",
|
||||||
"stirrer_speed": 0,
|
|
||||||
"user_message": "Bitte Malz einfüllen und bestätigen",
|
"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",
|
"descr": "Einmaischen Rast",
|
||||||
"duration" : 200,
|
"hold": {
|
||||||
"stirrer_speed": 30,
|
"duration": 2000
|
||||||
"stirrer_time_on": 30,
|
}
|
||||||
"stirrer_time_off": 30
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "heat",
|
|
||||||
"descr": "Heizen Glucose-Rast",
|
"descr": "Heizen Glucose-Rast",
|
||||||
|
"ramp": {
|
||||||
"rate": 1.0,
|
"rate": 1.0,
|
||||||
"temp": 63,
|
"temp": 63
|
||||||
"stirrer_speed": 50,
|
}
|
||||||
"stirrer_time_on": 90,
|
|
||||||
"stirrer_time_off": 0
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "hold",
|
|
||||||
"descr": "Halten Glucose-Rast",
|
"descr": "Halten Glucose-Rast",
|
||||||
"duration" : 400,
|
"hold": {
|
||||||
"stirrer_speed": 30,
|
"duration": 4000,
|
||||||
"stirrer_time_on": 30,
|
"stirrer": {
|
||||||
"stirrer_time_off": 30
|
"speed": 20,
|
||||||
|
"interval_time": 90,
|
||||||
|
"on_ratio": 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "heat",
|
|
||||||
"descr": "Heizen Verzuckerungs-Rast",
|
"descr": "Heizen Verzuckerungs-Rast",
|
||||||
|
"ramp": {
|
||||||
"rate": 1.0,
|
"rate": 1.0,
|
||||||
"temp": 72,
|
"temp": 72
|
||||||
"stirrer_speed": 50,
|
}
|
||||||
"stirrer_time_on": 90,
|
|
||||||
"stirrer_time_off": 0
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "hold",
|
|
||||||
"descr": "Halten Verzuckerungs-Rast",
|
"descr": "Halten Verzuckerungs-Rast",
|
||||||
"duration" : 300,
|
"hold": {
|
||||||
"stirrer_speed": 30,
|
"duration": 3000,
|
||||||
"stirrer_time_on": 30,
|
"stirrer": {
|
||||||
"stirrer_time_off": 30
|
"speed": 35,
|
||||||
|
"interval_time": 120
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "heat",
|
|
||||||
"descr": "Heizen Abmaischen",
|
"descr": "Heizen Abmaischen",
|
||||||
|
"ramp": {
|
||||||
"rate": 1.0,
|
"rate": 1.0,
|
||||||
"temp": 76,
|
"temp": 76
|
||||||
"stirrer_speed": 50,
|
}
|
||||||
"stirrer_time_on": 90,
|
|
||||||
"stirrer_time_off": 0
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "hold",
|
|
||||||
"descr": "Halten Abmaischen",
|
"descr": "Halten Abmaischen",
|
||||||
"duration" : 300,
|
"user_message": "Quittieren zum Abmaischen",
|
||||||
"stirrer_speed": 30,
|
"user_wait_for_continue": true,
|
||||||
"stirrer_time_on": 30,
|
"hold": {
|
||||||
"stirrer_time_off": 30,
|
"duration": 300,
|
||||||
"user_wait_for_continue" : true
|
"stirrer": {
|
||||||
|
"speed": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-18
@@ -19,34 +19,34 @@ class SudTask(ATask):
|
|||||||
msg_handler.set_recv_handler(self.recv)
|
msg_handler.set_recv_handler(self.recv)
|
||||||
|
|
||||||
def apply_stirrer(self, step):
|
def apply_stirrer(self, step):
|
||||||
speed = step.get('stirrer_speed', 0)
|
stirrer_cfg = step.get('ramp', step.get('hold', {})).get('stirrer', {})
|
||||||
on = step.get('stirrer_time_on', 0)
|
speed = stirrer_cfg.get('speed', 0)
|
||||||
off = step.get('stirrer_time_off', 0)
|
interval_time = stirrer_cfg.get('interval_time', 0)
|
||||||
cycle = on + off
|
on_ratio = stirrer_cfg.get('on_ratio', 1.0)
|
||||||
if cycle > 0:
|
if interval_time > 0:
|
||||||
duty = on / cycle
|
self.stirrer.set_cycle_time(interval_time)
|
||||||
|
self.stirrer.set_duty_cycle(on_ratio)
|
||||||
else:
|
else:
|
||||||
cycle = 1.0
|
self.stirrer.set_cycle_time(1.0)
|
||||||
duty = 1.0 if speed > 0 else 0.0
|
self.stirrer.set_duty_cycle(1.0 if speed > 0 else 0.0)
|
||||||
|
|
||||||
self.stirrer.set_cycle_time(cycle)
|
|
||||||
self.stirrer.set_duty_cycle(duty)
|
|
||||||
self.stirrer.set_speed(speed)
|
self.stirrer.set_speed(speed)
|
||||||
|
|
||||||
def on_step_changed(self, step):
|
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 is not None:
|
||||||
if step['type'] == 'heat':
|
if ramp is not None:
|
||||||
self.tc.set_theta_soll(step['temp'])
|
self.tc.set_theta_soll(ramp['temp'])
|
||||||
self.tc.set_heatrate_soll(step['rate'])
|
self.tc.set_heatrate_soll(ramp['rate'])
|
||||||
self.apply_stirrer(step)
|
self.apply_stirrer(step)
|
||||||
|
|
||||||
asyncio.create_task(self.send({'Step': {
|
asyncio.create_task(self.send({'Step': {
|
||||||
'Index': self.sud.index,
|
'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,
|
'Descr': step.get('descr') if step else None,
|
||||||
'Temp': step.get('temp') if step else None,
|
'Temp': ramp.get('temp') if ramp else None,
|
||||||
'Rate': step.get('rate') if step else None,
|
'Rate': ramp.get('rate') if ramp else None,
|
||||||
'Duration': step.get('duration') if step else None,
|
'Duration': hold.get('duration') if hold else None,
|
||||||
'WaitForUser': step.get('user_wait_for_continue', False) if step else None,
|
'WaitForUser': step.get('user_wait_for_continue', False) if step else None,
|
||||||
}}))
|
}}))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user