diff --git a/README.md b/README.md index 3a54c1e..0c77160 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,10 @@ scripts/demos/ Standalone, eyeballed-plot demos (matplotlib) `python -m scripts.demos.sud.demo_sud`. sude/ Mash schedules ("Sud" = brew/wort), each a - JSON list of temperature rests ("Rasten") - with target temperature, heat rate, and - optional pause for user confirmation. + JSON list of "heat"/"hold" steps with target + temperature/heat rate or hold duration, + per-step stirrer timing, and optional pause + for user confirmation. config.json.templ Configuration template (real hardware). config.json.sim Configuration template for simulation mode. @@ -128,26 +129,32 @@ environment (`$WORKON_HOME`/`$BREWPI_HOME`) on a Raspberry Pi-style deployment. ## Mash schedules Files under `sude/` describe a brew's mash schedule ("Sud"): pot weight, -malt/water weights, stirrer speed/duty, and a list of temperature rests -("Rasten"), each with a target `temp`, `heatRate`, hold `time` (minutes), and -whether to `waitForUser` before continuing (e.g. to add malt or check -gravity). +malt/water weights, and a flat `schedule` list of steps, each either: + +- `"type": "heat"` — ramp to `temp` at `rate` (°C/min), or +- `"type": "hold"` — hold the current target for `duration` minutes (omit + for an immediate, zero-duration 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 +add malt or check gravity, instead of advancing immediately. 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 rest and +automatically: `components/sud.py`'s `Sud` tracks the current step and `tasks/sud.py`'s `SudTask` drives the temperature controller's -`theta_soll`/`heatrate_soll` from it, advancing once `theta_ist` settles -close to the target and the rest's hold time has elapsed. It also switches -the stirrer between the schedule's `stirrSpeedHeat` (continuous, while -ramping) and `stirrSpeedRast`/`stirrDutyRast`/`stirrCycleTime` (intermittent, -while holding), stopping it entirely during a `waitForUser` pause. It -exposes progress (current rest, remaining hold time, state) on the `"Sud"` -WebSocket channel and accepts `{"Sud": {"Start": true}}` to begin the -schedule and `{"Sud": {"Confirm": true}}` to acknowledge a `waitForUser` -pause and move to the next rest. 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 `"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). ## Logging & analysis diff --git a/components/sud.py b/components/sud.py index 220adfe..d928a2e 100644 --- a/components/sud.py +++ b/components/sud.py @@ -19,17 +19,13 @@ class Sud(AttributeChange): self.name = data.get('Name', '') self.description = data.get('Description', '') - self.rasten = data['Rasten'] - - self.stirr_speed_heat = data.get('stirrSpeedHeat', 0) - self.stirr_speed_rast = data.get('stirrSpeedRast', 0) - self.stirr_duty_rast = data.get('stirrDutyRast', 1.0) - self.stirr_cycle_time = data.get('stirrCycleTime', 1.0) + self.schedule = data['schedule'] self.index = -1 self.hold_remaining = 0.0 self.state = SudState.IDLE - self.rast = None + self.step = None + self.user_message = None def start(self): if self.state != SudState.IDLE: @@ -43,28 +39,34 @@ class Sud(AttributeChange): def temp_reached(self): if self.state == SudState.RAMPING: - self.state = SudState.HOLDING + self._finish_step() def tick(self, dt): if self.state == SudState.HOLDING: self.hold_remaining -= dt if self.hold_remaining <= 0: - self._finish_rast() + self._finish_step() def _advance(self): self.index += 1 - if self.index >= len(self.rasten): + if self.index >= len(self.schedule): self.state = SudState.DONE - self.rast = None + self.user_message = None + self.step = None return - next_rast = self.rasten[self.index] - self.hold_remaining = next_rast['time'] * 60.0 - self.state = SudState.RAMPING - self.rast = next_rast + next_step = self.schedule[self.index] + if next_step['type'] == 'heat': + self.state = SudState.RAMPING + else: + self.hold_remaining = next_step.get('duration', 0) * 60.0 + self.state = SudState.HOLDING - def _finish_rast(self): - if self.rast.get('waitForUser', False): + self.user_message = next_step.get('user_message') + self.step = next_step + + def _finish_step(self): + if self.step.get('user_wait_for_continue', False): self.state = SudState.WAIT_USER else: self._advance() diff --git a/scripts/demos/sud/demo_sud.py b/scripts/demos/sud/demo_sud.py index 287e78e..b8768d7 100644 --- a/scripts/demos/sud/demo_sud.py +++ b/scripts/demos/sud/demo_sud.py @@ -52,24 +52,34 @@ if __name__ == '__main__': heater.set_on_changed('power_set', ctrl.set_model_power) heater.set_on_changed('power_eff', plant.set_power) - def on_rast_changed(rast): - if rast is not None: - ctrl.set_theta_soll(rast['temp']) - ctrl.set_heatrate_soll(rast['heatRate']) + 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 + 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_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']) + apply_stirrer(step) def on_state_changed(state): - if state == SudState.RAMPING: - stirrer.set_duty_cycle(1.0) - stirrer.set_speed(sud.stirr_speed_heat) - elif state == SudState.HOLDING: - stirrer.set_cycle_time(sud.stirr_cycle_time) - stirrer.set_duty_cycle(sud.stirr_duty_rast) - stirrer.set_speed(sud.stirr_speed_rast) - elif state in (SudState.WAIT_USER, SudState.DONE): + if state == SudState.DONE: stirrer.set_duty_cycle(1.0) stirrer.set_speed(0) - sud.set_on_changed('rast', on_rast_changed) + sud.set_on_changed('step', on_step_changed) sud.set_on_changed('state', on_state_changed) sud.start() @@ -80,7 +90,7 @@ if __name__ == '__main__': _heatrate_ist = [] _heatrate_soll = [] _sud_state = [] - _rast_index = [] + _step_index = [] _stirrer_speed = [] _stirrer_duty = [] _heater_power_set = [] @@ -121,7 +131,7 @@ if __name__ == '__main__': _heatrate_ist.append(ctrl.get_heatrate_ist()) _heatrate_soll.append(ctrl.get_heatrate_soll()) _sud_state.append(sud.state.value) - _rast_index.append(sud.index) + _step_index.append(sud.index) _stirrer_speed.append(stirrer.speed * stirrer.isOn) _stirrer_duty.append(stirrer.dutyCycle * 100) _heater_power_set.append(heater.power_set) @@ -157,8 +167,8 @@ if __name__ == '__main__': legend(["Sud state"]) grid(True) subplot(3, 1, 2) - step(_t_min, _rast_index, where='post', color='k') - legend(["Rast index"]) + step(_t_min, _step_index, where='post', color='k') + legend(["Step index"]) grid(True) subplot(3, 1, 3) plot(_t_min, _stirrer_speed, '-g', _t_min, _stirrer_duty, '-c', linewidth=1) diff --git a/sude/sud_0010.json b/sude/sud_0010.json index 716a817..d1c8962 100644 --- a/sude/sud_0010.json +++ b/sude/sud_0010.json @@ -5,35 +5,83 @@ "pot_material" : "Edelstahl 18/10", "Schuettung_kg" : 5.21, "Wasser_kg" : 22, - "stirrSpeedHeat" : 30, - "stirrSpeedRast" : 30, - "stirrDutyRast" : 1.0, - "stirrCycleTime" : 120, - "Rasten" : + "schedule": [ { - "time" : 0, - "temp" : 57.0, - "heatRate" : 1.00, - "waitForUser" : true + "type": "heat", + "descr": "Heizen für Einmaischen", + "rate": 1.8, + "temp": 57, + "stirrer_speed": 50, + "stirrer_time_on": 90, + "stirrer_time_off": 0 }, { - "time" : 40, - "temp" : 63.0, - "heatRate" : 1.00, - "waitForUser" : false + "type": "hold", + "descr": "Einmaischen einfüllen", + "stirrer_speed": 0, + "user_message": "Bitte Malz einfüllen und bestätigen", + "user_wait_for_continue" : true }, { - "time" : 30, - "temp" : 72.0, - "heatRate" : 1.00, - "waitForUser" : true + "type": "hold", + "descr": "Einmaischen Rast", + "duration" : 30, + "stirrer_speed": 30, + "stirrer_time_on": 30, + "stirrer_time_off": 30 }, { - "time" : 0, - "temp" : 76.0, - "heatRate" : 1.00, - "waitForUser" : true + "type": "heat", + "descr": "Heizen Glucose-Rast", + "rate": 1.0, + "temp": 63, + "stirrer_speed": 50, + "stirrer_time_on": 90, + "stirrer_time_off": 0 + }, + { + "type": "hold", + "descr": "Halten Glucose-Rast", + "duration" : 40, + "stirrer_speed": 30, + "stirrer_time_on": 30, + "stirrer_time_off": 30 + }, + { + "type": "heat", + "descr": "Heizen Verzuckerungs-Rast", + "rate": 1.0, + "temp": 72, + "stirrer_speed": 50, + "stirrer_time_on": 90, + "stirrer_time_off": 0 + }, + { + "type": "hold", + "descr": "Halten Verzuckerungs-Rast", + "duration" : 30, + "stirrer_speed": 30, + "stirrer_time_on": 30, + "stirrer_time_off": 30 + }, + { + "type": "heat", + "descr": "Heizen Abmaischen", + "rate": 1.0, + "temp": 76, + "stirrer_speed": 50, + "stirrer_time_on": 90, + "stirrer_time_off": 0 + }, + { + "type": "hold", + "descr": "Halten Abmaischen", + "duration" : 30, + "stirrer_speed": 30, + "stirrer_time_on": 30, + "stirrer_time_off": 30, + "user_wait_for_continue" : true } ] } diff --git a/tasks/sud.py b/tasks/sud.py index 77369b3..0ddf9a2 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -18,36 +18,48 @@ class SudTask(ATask): self.msg_handler = msg_handler msg_handler.set_recv_handler(self.recv) - def on_rast_changed(self, rast): - if rast is not None: - self.tc.set_theta_soll(rast['temp']) - self.tc.set_heatrate_soll(rast['heatRate']) + 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 + else: + cycle = 1.0 + duty = 1.0 if speed > 0 else 0.0 - asyncio.create_task(self.send({'Rast': { + self.stirrer.set_cycle_time(cycle) + self.stirrer.set_duty_cycle(duty) + self.stirrer.set_speed(speed) + + def on_step_changed(self, step): + if step is not None: + if step['type'] == 'heat': + self.tc.set_theta_soll(step['temp']) + self.tc.set_heatrate_soll(step['rate']) + self.apply_stirrer(step) + + asyncio.create_task(self.send({'Step': { 'Index': self.sud.index, - 'Temp': rast['temp'] if rast else None, - 'HeatRate': rast['heatRate'] if rast else None, - 'Time': rast['time'] if rast else None, - 'WaitForUser': rast.get('waitForUser', False) if rast else None, + 'Type': step['type'] if step 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, + 'WaitForUser': step.get('user_wait_for_continue', False) if step else None, }})) def on_state_changed(self, value): asyncio.create_task(self.send({'State': str(value)})) - if value == SudState.RAMPING: - # Stir continuously while ramping to a new target temperature. - self.stirrer.set_duty_cycle(1.0) - self.stirrer.set_speed(self.sud.stirr_speed_heat) - elif value == SudState.HOLDING: - # Stir intermittently while holding at the rest's temperature. - self.stirrer.set_cycle_time(self.sud.stirr_cycle_time) - self.stirrer.set_duty_cycle(self.sud.stirr_duty_rast) - self.stirrer.set_speed(self.sud.stirr_speed_rast) - elif value in (SudState.WAIT_USER, SudState.DONE): - # Stop stirring while paused for the user or once the schedule is done. + if value == SudState.DONE: self.stirrer.set_duty_cycle(1.0) self.stirrer.set_speed(0) + def on_user_message_changed(self, value): + asyncio.create_task(self.send({'UserMessage': value})) + def on_hold_remaining_changed(self, value): asyncio.create_task(self.send({'HoldRemaining': value})) @@ -64,8 +76,9 @@ class SudTask(ATask): async def on_process(self): print("{}: Started with interval {} s".format(self.msg_handler.get_key(), self.interval)) - self.sud.set_on_changed('rast', self.on_rast_changed) + self.sud.set_on_changed('step', self.on_step_changed) self.sud.set_on_changed('state', self.on_state_changed) + self.sud.set_on_changed('user_message', self.on_user_message_changed) self.sud.set_on_changed('hold_remaining', ChangedFloat(self.on_hold_remaining_changed, prec=0).set) asyncio.create_task(self.send({'Name': self.sud.name, 'Description': self.sud.description})) @@ -74,7 +87,7 @@ class SudTask(ATask): if self.sud.state == SudState.RAMPING: # Compare against the controller's own live setpoint/measurement # rather than its state machine, which can still read HOLD from - # the previous rest for one tick after a new target is pushed. + # the previous step for one tick after a new target is pushed. if abs(self.tc.get_theta_ist() - self.tc.get_theta_soll_set()) < TEMP_REACHED_TOLERANCE: self.sud.temp_reached() self.sud.tick(self.interval)