Allow a Sud step to combine a ramp and a hold
A step can now carry both 'ramp' and 'hold': it ramps to the target temp, then holds there for the given duration, instead of needing two separate schedule entries. Sud.temp_reached() switches such a step from its ramp phase into its hold phase in place (re-firing the step-changed callback so SudTask/demo_sud re-apply the hold's stirrer settings); user_wait_for_continue still applies once the whole step - both phases - is done. Merges sud_0010.json's three ramp/hold pairs into combined steps.
This commit is contained in:
@@ -129,13 +129,19 @@ environment (`$WORKON_HOME`/`$BREWPI_HOME`) on a Raspberry Pi-style deployment.
|
|||||||
## Mash schedules
|
## Mash schedules
|
||||||
|
|
||||||
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` (fixed for the whole brew), and a `steps` list, each either:
|
`pot_material` (fixed for the whole brew), and a `steps` list, each a ramp, a
|
||||||
|
hold, or both:
|
||||||
|
|
||||||
- a ramp — `"ramp": {"rate": ..., "temp": ...}` — ramp to `temp` at `rate`
|
- a ramp — `"ramp": {"rate": ..., "temp": ...}` — ramp to `temp` at `rate`
|
||||||
(°C/min), or
|
(°C/min), and/or
|
||||||
- a hold — `"hold": {"duration": ...}` — hold the current target for
|
- a hold — `"hold": {"duration": ...}` — hold the current target for
|
||||||
`duration` minutes (omit/`0` for an immediate step).
|
`duration` minutes (omit/`0` for an immediate step).
|
||||||
|
|
||||||
|
A step with both ramps to `temp` and then holds there for `duration` -
|
||||||
|
useful for a mash rest ("ramp to 63°C, then hold 40 min") without needing two
|
||||||
|
separate schedule entries. `user_wait_for_continue`/`user_message` (below)
|
||||||
|
apply once the whole step is done, i.e. after the hold phase if there is one.
|
||||||
|
|
||||||
Both `ramp` and `hold` carry a `stirrer` block (`speed`, `interval_time`,
|
Both `ramp` and `hold` carry a `stirrer` block (`speed`, `interval_time`,
|
||||||
`on_ratio`): `interval_time: 0` runs the stirrer continuously at `speed`;
|
`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
|
`interval_time > 0` pulses it on a period of `interval_time` seconds, on for
|
||||||
|
|||||||
@@ -130,7 +130,8 @@ class SudForecastPlot(FigureCanvasQTAgg):
|
|||||||
def _estimate_course(schedule, start_theta):
|
def _estimate_course(schedule, start_theta):
|
||||||
"""Walks the schedule, accumulating (t, theta) waypoints: ramps take
|
"""Walks the schedule, accumulating (t, theta) waypoints: ramps take
|
||||||
abs(delta)/rate minutes at the declared rate, holds last for their
|
abs(delta)/rate minutes at the declared rate, holds last for their
|
||||||
duration at the temperature the preceding ramp reached."""
|
duration at the temperature the preceding ramp reached. A step may
|
||||||
|
carry both - ramp then hold - contributing both segments in turn."""
|
||||||
t = [0.0]
|
t = [0.0]
|
||||||
theta = [start_theta]
|
theta = [start_theta]
|
||||||
for step in schedule:
|
for step in schedule:
|
||||||
@@ -142,7 +143,7 @@ class SudForecastPlot(FigureCanvasQTAgg):
|
|||||||
if rate > 0:
|
if rate > 0:
|
||||||
t.append(t[-1] + abs(target - theta[-1]) / rate * 60.0)
|
t.append(t[-1] + abs(target - theta[-1]) / rate * 60.0)
|
||||||
theta.append(target)
|
theta.append(target)
|
||||||
elif hold is not None:
|
if hold is not None:
|
||||||
t.append(t[-1] + hold.get('duration', 0) * 60.0)
|
t.append(t[-1] + hold.get('duration', 0) * 60.0)
|
||||||
theta.append(theta[-1])
|
theta.append(theta[-1])
|
||||||
return t, theta
|
return t, theta
|
||||||
|
|||||||
+12
-1
@@ -157,7 +157,18 @@ class Sud(AttributeChange):
|
|||||||
self._advance()
|
self._advance()
|
||||||
|
|
||||||
def temp_reached(self):
|
def temp_reached(self):
|
||||||
if self.state == SudState.RAMPING:
|
if self.state != SudState.RAMPING:
|
||||||
|
return
|
||||||
|
if 'hold' in self.step:
|
||||||
|
# Same step, ramp phase done - move into its hold phase rather
|
||||||
|
# than finishing the step. Re-assign self.step (AttributeChange
|
||||||
|
# fires on every assignment, not just on change) to re-trigger
|
||||||
|
# the step-changed callback so e.g. the hold's stirrer settings
|
||||||
|
# get (re-)applied.
|
||||||
|
self.hold_remaining = self.step['hold'].get('duration', 0) * 60.0
|
||||||
|
self.state = SudState.HOLDING
|
||||||
|
self.step = self.step
|
||||||
|
else:
|
||||||
self._finish_step()
|
self._finish_step()
|
||||||
|
|
||||||
def tick(self, dt):
|
def tick(self, dt):
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ if __name__ == '__main__':
|
|||||||
heater.set_on_changed('power_set', ctrl.set_model_power)
|
heater.set_on_changed('power_set', ctrl.set_model_power)
|
||||||
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(phase):
|
||||||
stirrer_cfg = step.get('ramp', step.get('hold', {})).get('stirrer', {})
|
stirrer_cfg = phase.get('stirrer', {})
|
||||||
speed = stirrer_cfg.get('speed', 0)
|
speed = stirrer_cfg.get('speed', 0)
|
||||||
interval_time = stirrer_cfg.get('interval_time', 0)
|
interval_time = stirrer_cfg.get('interval_time', 0)
|
||||||
on_ratio = stirrer_cfg.get('on_ratio', 1.0)
|
on_ratio = stirrer_cfg.get('on_ratio', 1.0)
|
||||||
@@ -72,13 +72,17 @@ if __name__ == '__main__':
|
|||||||
ctrl.set_model_params(params['M'], params['C'])
|
ctrl.set_model_params(params['M'], params['C'])
|
||||||
|
|
||||||
def on_step_changed(step):
|
def on_step_changed(step):
|
||||||
|
# A step may carry both 'ramp' and 'hold' (ramp to temp, then hold);
|
||||||
|
# sud.state tells us which phase is currently active.
|
||||||
if step is not None:
|
if step is not None:
|
||||||
|
ramping = sud.state == SudState.RAMPING
|
||||||
|
phase = step['ramp'] if ramping and 'ramp' in step else step.get('hold', {})
|
||||||
print(f"Step {step['number']}: {step['descr']}")
|
print(f"Step {step['number']}: {step['descr']}")
|
||||||
apply_plant_params(step)
|
apply_plant_params(step)
|
||||||
if 'ramp' in step:
|
if ramping and 'ramp' in step:
|
||||||
ctrl.set_theta_soll(step['ramp']['temp'])
|
ctrl.set_theta_soll(step['ramp']['temp'])
|
||||||
ctrl.set_heatrate_soll(step['ramp']['rate'])
|
ctrl.set_heatrate_soll(step['ramp']['rate'])
|
||||||
apply_stirrer(step)
|
apply_stirrer(phase)
|
||||||
|
|
||||||
def on_state_changed(state):
|
def on_state_changed(state):
|
||||||
if state == SudState.WAIT_USER and sud.user_message:
|
if state == SudState.WAIT_USER and sud.user_message:
|
||||||
|
|||||||
+12
-22
@@ -54,20 +54,17 @@
|
|||||||
{
|
{
|
||||||
"descr": "Einmaischen Rast",
|
"descr": "Einmaischen Rast",
|
||||||
"hold": {
|
"hold": {
|
||||||
"duration": 2000
|
"duration": 20
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"descr": "Heizen Glucose-Rast",
|
"descr": "Glucose-Rast",
|
||||||
"ramp": {
|
"ramp": {
|
||||||
"rate": 1.0,
|
"rate": 1.0,
|
||||||
"temp": 63
|
"temp": 63
|
||||||
}
|
},
|
||||||
},
|
|
||||||
{
|
|
||||||
"descr": "Halten Glucose-Rast",
|
|
||||||
"hold": {
|
"hold": {
|
||||||
"duration": 4000,
|
"duration": 40,
|
||||||
"stirrer": {
|
"stirrer": {
|
||||||
"speed": 20,
|
"speed": 20,
|
||||||
"interval_time": 90,
|
"interval_time": 90,
|
||||||
@@ -76,16 +73,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"descr": "Heizen Verzuckerungs-Rast",
|
"descr": "Verzuckerungs-Rast",
|
||||||
"ramp": {
|
"ramp": {
|
||||||
"rate": 1.0,
|
"rate": 1.0,
|
||||||
"temp": 72
|
"temp": 72
|
||||||
}
|
},
|
||||||
},
|
|
||||||
{
|
|
||||||
"descr": "Halten Verzuckerungs-Rast",
|
|
||||||
"hold": {
|
"hold": {
|
||||||
"duration": 3000,
|
"duration": 30,
|
||||||
"stirrer": {
|
"stirrer": {
|
||||||
"speed": 35,
|
"speed": 35,
|
||||||
"interval_time": 120
|
"interval_time": 120
|
||||||
@@ -94,19 +88,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"water_mass": 20,
|
"water_mass": 20,
|
||||||
"descr": "Heizen Abmaischen",
|
"descr": "Abmaischen",
|
||||||
|
"user_message": "Quittieren zum Abmaischen",
|
||||||
|
"user_wait_for_continue": true,
|
||||||
"ramp": {
|
"ramp": {
|
||||||
"rate": 1.0,
|
"rate": 1.0,
|
||||||
"temp": 76
|
"temp": 76
|
||||||
}
|
},
|
||||||
},
|
|
||||||
{
|
|
||||||
"water_mass": 20,
|
|
||||||
"descr": "Halten Abmaischen",
|
|
||||||
"user_message": "Quittieren zum Abmaischen",
|
|
||||||
"user_wait_for_continue": true,
|
|
||||||
"hold": {
|
"hold": {
|
||||||
"duration": 300,
|
"duration": 30,
|
||||||
"stirrer": {
|
"stirrer": {
|
||||||
"speed": 50
|
"speed": 50
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-6
@@ -37,8 +37,8 @@ class SudTask(ATask):
|
|||||||
if hasattr(self.tc, 'set_model_params'):
|
if hasattr(self.tc, 'set_model_params'):
|
||||||
self.tc.set_model_params(params['M'], params['C'])
|
self.tc.set_model_params(params['M'], params['C'])
|
||||||
|
|
||||||
def apply_stirrer(self, step):
|
def apply_stirrer(self, phase):
|
||||||
stirrer_cfg = step.get('ramp', step.get('hold', {})).get('stirrer', {})
|
stirrer_cfg = phase.get('stirrer', {})
|
||||||
speed = stirrer_cfg.get('speed', 0)
|
speed = stirrer_cfg.get('speed', 0)
|
||||||
interval_time = stirrer_cfg.get('interval_time', 0)
|
interval_time = stirrer_cfg.get('interval_time', 0)
|
||||||
on_ratio = stirrer_cfg.get('on_ratio', 1.0)
|
on_ratio = stirrer_cfg.get('on_ratio', 1.0)
|
||||||
@@ -53,20 +53,28 @@ class SudTask(ATask):
|
|||||||
def on_step_changed(self, step):
|
def on_step_changed(self, step):
|
||||||
ramp = step.get('ramp') if step else None
|
ramp = step.get('ramp') if step else None
|
||||||
hold = step.get('hold') if step else None
|
hold = step.get('hold') if step else None
|
||||||
|
# A step may carry both 'ramp' and 'hold' (ramp to temp, then hold) -
|
||||||
|
# self.sud.state tells us which phase is currently active; it's
|
||||||
|
# already up to date by the time this callback fires, both on a
|
||||||
|
# full step transition and on the ramp->hold phase switch within
|
||||||
|
# one step (components/sud.py's temp_reached()).
|
||||||
|
ramping = self.sud.state == SudState.RAMPING
|
||||||
|
phase = ramp if ramping and ramp is not None else hold
|
||||||
|
|
||||||
if step is not None:
|
if step is not None:
|
||||||
self.apply_plant_params(step)
|
self.apply_plant_params(step)
|
||||||
if ramp is not None:
|
if ramping and ramp is not None:
|
||||||
self.tc.set_theta_soll(ramp['temp'])
|
self.tc.set_theta_soll(ramp['temp'])
|
||||||
self.tc.set_heatrate_soll(ramp['rate'])
|
self.tc.set_heatrate_soll(ramp['rate'])
|
||||||
self.apply_stirrer(step)
|
self.apply_stirrer(phase)
|
||||||
|
|
||||||
asyncio.create_task(self.send({'Step': {
|
asyncio.create_task(self.send({'Step': {
|
||||||
'Index': self.sud.index,
|
'Index': self.sud.index,
|
||||||
'Type': 'ramp' if ramp is not None else 'hold' if hold is not None else None,
|
'Type': 'ramp' if ramping and 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': ramp.get('temp') if ramp else None,
|
'Temp': ramp.get('temp') if ramp else None,
|
||||||
'Rate': ramp.get('rate') if ramp else None,
|
'Rate': ramp.get('rate') if ramp else None,
|
||||||
'Duration': hold.get('duration') if hold else None,
|
'Duration': hold.get('duration') if (hold is not None and not ramping) 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