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`,
|
||||
`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.
|
||||
in `scripts/demos/sud/demo_sud.py`, instead of hand-tuning them), and a flat
|
||||
`schedule` list of steps, each either:
|
||||
in `scripts/demos/sud/demo_sud.py`, instead of hand-tuning them), and a
|
||||
`steps` list, each either:
|
||||
|
||||
- `"type": "heat"` — ramp to `temp` at `rate` (°C/min), or
|
||||
- `"type": "hold"` — hold the current target for `duration` seconds (omit
|
||||
for an immediate, zero-duration step).
|
||||
- a ramp — `"ramp": {"rate": ..., "temp": ...}` — ramp to `temp` at `rate`
|
||||
(°C/min), or
|
||||
- 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`,
|
||||
`stirrer_time_on`/`stirrer_time_off` in seconds — `time_off: 0` means
|
||||
continuous), and may set `user_wait_for_continue: true` (with an optional
|
||||
`user_message`) to pause for confirmation once the step completes, e.g. to
|
||||
Both `ramp` and `hold` carry a `stirrer` block (`speed`, `interval_time`,
|
||||
`on_ratio`): `interval_time: 0` runs the stirrer continuously at `speed`;
|
||||
`interval_time > 0` pulses it on a period of `interval_time` seconds, on for
|
||||
`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.
|
||||
|
||||
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
|
||||
`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
|
||||
`theta_soll`/`heatrate_soll` from `"heat"` steps (advancing once `theta_ist`
|
||||
settles close to the target) and counts down `"hold"` steps' `duration`,
|
||||
applying each step's stirrer timing as it goes (converting
|
||||
`stirrer_time_on`/`stirrer_time_off` into a duty cycle/cycle time). 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).
|
||||
`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).
|
||||
|
||||
## Logging & analysis
|
||||
|
||||
|
||||
+30
-3
@@ -20,6 +20,31 @@ class SudState(Enum):
|
||||
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):
|
||||
def __init__(self, path):
|
||||
AttributeChange.__init__(self)
|
||||
@@ -28,7 +53,9 @@ class Sud(AttributeChange):
|
||||
|
||||
self.name = data.get('Name', '')
|
||||
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_material = data.get('pot_material')
|
||||
@@ -84,10 +111,10 @@ class Sud(AttributeChange):
|
||||
return
|
||||
|
||||
next_step = self.schedule[self.index]
|
||||
if next_step['type'] == 'heat':
|
||||
if 'ramp' in next_step:
|
||||
self.state = SudState.RAMPING
|
||||
else:
|
||||
self.hold_remaining = next_step.get('duration', 0)
|
||||
self.hold_remaining = next_step['hold'].get('duration', 0)
|
||||
self.state = SudState.HOLDING
|
||||
|
||||
self.user_message = next_step.get('user_message')
|
||||
|
||||
@@ -27,7 +27,7 @@ if __name__ == '__main__':
|
||||
},
|
||||
"Heat": {
|
||||
"kp": 0.08,
|
||||
"ki": 0.008,
|
||||
"ki": 0.02,
|
||||
"kd": 0.0,
|
||||
"kt": 1.5
|
||||
}
|
||||
@@ -38,7 +38,7 @@ if __name__ == '__main__':
|
||||
plant_params = {
|
||||
**sud.derive_plant_params(),
|
||||
"L" : 0.2,
|
||||
"Td" : 30
|
||||
"Td" : 60
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
def apply_stirrer(step):
|
||||
speed = step.get('stirrer_speed', 0)
|
||||
on = step.get('stirrer_time_on', 0)
|
||||
off = step.get('stirrer_time_off', 0)
|
||||
cycle = on + off
|
||||
if cycle > 0:
|
||||
duty = on / cycle
|
||||
stirrer_cfg = step.get('ramp', step.get('hold', {})).get('stirrer', {})
|
||||
speed = stirrer_cfg.get('speed', 0)
|
||||
interval_time = stirrer_cfg.get('interval_time', 0)
|
||||
on_ratio = stirrer_cfg.get('on_ratio', 1.0)
|
||||
if interval_time > 0:
|
||||
stirrer.set_cycle_time(interval_time)
|
||||
stirrer.set_duty_cycle(on_ratio)
|
||||
else:
|
||||
cycle = 1.0
|
||||
duty = 1.0 if speed > 0 else 0.0
|
||||
|
||||
stirrer.set_cycle_time(cycle)
|
||||
stirrer.set_duty_cycle(duty)
|
||||
stirrer.set_cycle_time(1.0)
|
||||
stirrer.set_duty_cycle(1.0 if speed > 0 else 0.0)
|
||||
stirrer.set_speed(speed)
|
||||
|
||||
def on_step_changed(step):
|
||||
if step is not None:
|
||||
if step['type'] == 'heat':
|
||||
ctrl.set_theta_soll(step['temp'])
|
||||
ctrl.set_heatrate_soll(step['rate'])
|
||||
if 'ramp' in step:
|
||||
ctrl.set_theta_soll(step['ramp']['temp'])
|
||||
ctrl.set_heatrate_soll(step['ramp']['rate'])
|
||||
apply_stirrer(step)
|
||||
|
||||
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_speed(0)
|
||||
|
||||
|
||||
+72
-44
@@ -5,83 +5,111 @@
|
||||
"pot_material" : "Edelstahl 18/10",
|
||||
"grain_mass" : 5.21,
|
||||
"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",
|
||||
"ramp": {
|
||||
"rate": 1.8,
|
||||
"temp": 57,
|
||||
"stirrer_speed": 50,
|
||||
"stirrer_time_on": 90,
|
||||
"stirrer_time_off": 0
|
||||
"stirrer": {
|
||||
"speed": 75
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "hold",
|
||||
"descr": "Einmaischen einfüllen",
|
||||
"stirrer_speed": 0,
|
||||
"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",
|
||||
"duration" : 200,
|
||||
"stirrer_speed": 30,
|
||||
"stirrer_time_on": 30,
|
||||
"stirrer_time_off": 30
|
||||
"hold": {
|
||||
"duration": 2000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "heat",
|
||||
"descr": "Heizen Glucose-Rast",
|
||||
"ramp": {
|
||||
"rate": 1.0,
|
||||
"temp": 63,
|
||||
"stirrer_speed": 50,
|
||||
"stirrer_time_on": 90,
|
||||
"stirrer_time_off": 0
|
||||
"temp": 63
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "hold",
|
||||
"descr": "Halten Glucose-Rast",
|
||||
"duration" : 400,
|
||||
"stirrer_speed": 30,
|
||||
"stirrer_time_on": 30,
|
||||
"stirrer_time_off": 30
|
||||
"hold": {
|
||||
"duration": 4000,
|
||||
"stirrer": {
|
||||
"speed": 20,
|
||||
"interval_time": 90,
|
||||
"on_ratio": 0.8
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "heat",
|
||||
"descr": "Heizen Verzuckerungs-Rast",
|
||||
"ramp": {
|
||||
"rate": 1.0,
|
||||
"temp": 72,
|
||||
"stirrer_speed": 50,
|
||||
"stirrer_time_on": 90,
|
||||
"stirrer_time_off": 0
|
||||
"temp": 72
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "hold",
|
||||
"descr": "Halten Verzuckerungs-Rast",
|
||||
"duration" : 300,
|
||||
"stirrer_speed": 30,
|
||||
"stirrer_time_on": 30,
|
||||
"stirrer_time_off": 30
|
||||
"hold": {
|
||||
"duration": 3000,
|
||||
"stirrer": {
|
||||
"speed": 35,
|
||||
"interval_time": 120
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "heat",
|
||||
"descr": "Heizen Abmaischen",
|
||||
"ramp": {
|
||||
"rate": 1.0,
|
||||
"temp": 76,
|
||||
"stirrer_speed": 50,
|
||||
"stirrer_time_on": 90,
|
||||
"stirrer_time_off": 0
|
||||
"temp": 76
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "hold",
|
||||
"descr": "Halten Abmaischen",
|
||||
"duration" : 300,
|
||||
"stirrer_speed": 30,
|
||||
"stirrer_time_on": 30,
|
||||
"stirrer_time_off": 30,
|
||||
"user_wait_for_continue" : true
|
||||
"user_message": "Quittieren zum Abmaischen",
|
||||
"user_wait_for_continue": true,
|
||||
"hold": {
|
||||
"duration": 300,
|
||||
"stirrer": {
|
||||
"speed": 50
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+18
-18
@@ -19,34 +19,34 @@ class SudTask(ATask):
|
||||
msg_handler.set_recv_handler(self.recv)
|
||||
|
||||
def apply_stirrer(self, step):
|
||||
speed = step.get('stirrer_speed', 0)
|
||||
on = step.get('stirrer_time_on', 0)
|
||||
off = step.get('stirrer_time_off', 0)
|
||||
cycle = on + off
|
||||
if cycle > 0:
|
||||
duty = on / cycle
|
||||
stirrer_cfg = step.get('ramp', step.get('hold', {})).get('stirrer', {})
|
||||
speed = stirrer_cfg.get('speed', 0)
|
||||
interval_time = stirrer_cfg.get('interval_time', 0)
|
||||
on_ratio = stirrer_cfg.get('on_ratio', 1.0)
|
||||
if interval_time > 0:
|
||||
self.stirrer.set_cycle_time(interval_time)
|
||||
self.stirrer.set_duty_cycle(on_ratio)
|
||||
else:
|
||||
cycle = 1.0
|
||||
duty = 1.0 if speed > 0 else 0.0
|
||||
|
||||
self.stirrer.set_cycle_time(cycle)
|
||||
self.stirrer.set_duty_cycle(duty)
|
||||
self.stirrer.set_cycle_time(1.0)
|
||||
self.stirrer.set_duty_cycle(1.0 if speed > 0 else 0.0)
|
||||
self.stirrer.set_speed(speed)
|
||||
|
||||
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['type'] == 'heat':
|
||||
self.tc.set_theta_soll(step['temp'])
|
||||
self.tc.set_heatrate_soll(step['rate'])
|
||||
if ramp is not None:
|
||||
self.tc.set_theta_soll(ramp['temp'])
|
||||
self.tc.set_heatrate_soll(ramp['rate'])
|
||||
self.apply_stirrer(step)
|
||||
|
||||
asyncio.create_task(self.send({'Step': {
|
||||
'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,
|
||||
'Temp': step.get('temp') if step else None,
|
||||
'Rate': step.get('rate') if step else None,
|
||||
'Duration': step.get('duration') if step else None,
|
||||
'Temp': ramp.get('temp') if ramp else None,
|
||||
'Rate': ramp.get('rate') if ramp else None,
|
||||
'Duration': hold.get('duration') if hold else None,
|
||||
'WaitForUser': step.get('user_wait_for_continue', False) if step else None,
|
||||
}}))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user